Jskalc

Jskalc

Hi everyone! Recently I was thinking a lot about the way HEEX renders lists. People are generally surprised about huge payloads being send on any list change, and I had to work around this in one of my recent projects to keep site responsive.

I think there’s a possible payload optimisation inspired by client-side VDOM implementations. Let me explain by providing a very simple phoenix playground rendering a list.

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

defmodule DemoLive do
  use Phoenix.LiveView

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

    <ul>
      <li :for={i <- @items}>
        <%= i.name %>
      </li>
    </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 problem

LiveView has many exciting optimizations, but they mostly doesn’t apply if you’re using a “:for” loop. In my basic example, on each list update LiveView sends all the elements again, even if most of them are exactly the same as before.

In this case, problem could be solved trivially by using Streams. But in many other cases it’s not feasible because:

  • Streams works only as top-level assigns. If you have list as an attribute of an object (eg. ecto associations like current_user.posts) then using streams is not easy.
  • Streams API is different. You need to understand how exactly list changes. It might complicate your business layer (contexts) just to get that information.
  • Streams gives you both memory optimization and payload optimization. I believe second one should be available out of the box, even without using streams.

Idea

This problem is not new. Multiple frontend frameworks use Virtual DOM, where they need to calculate a minimal patch between VDOM and DOM. They simply require :key attribute when rendering a list, for example Vue.js

Then, it’s rather trivial to figure out:

  • if element is new (key not present in the old state, but present in the new one)
  • if element was removed (key present in the old state, but no longer in the new one)
  • if element was updated (key present both at old and new state, then we should calculate diff recursively for these elements)

So, maybe LiveView could go the same route? If we could introduce :key as an optional attribute, HEEX engine could calculate:

  • added elements (and their positions)
  • removed elements
  • updated elements (and their positions)

and send an efficient, minimal payload to the client. This actually could even enable efficient diffs for components nested below :for loops.

An example (notice added :key):

  def render(assigns) do
    ~H"""
    <ul>
      <li :for={i <- @items} :key={i.id}>
        <%= i.name %>
      </li>
    </ul>
    """
  end

Implications

  • This would require to keep previous assign of a list around in assigns.__changed__, which is currently not the case.
  • This would impose a small overhead of calculating old keys, new keys and figuring what was updated, but only when :key is present. I think it’s worth to do it and send a smaller payload than to send everything.
  • Reordering might require some thoughts
  • I’m not sure if it would be a breaking change, possibly not?

All in all, I believe that optimization should be possible to accomplish. I might give it a shot myself, just wanted to first ask community for some feedback :wink: What’s your opinion?

EDIT: I wasn’t aware it’s a category only for registered users, could someone move it to phoenix questions or some other public place? :see_no_evil_monkey: Would like to refer this in github PR / issue if I’ll be able to sit down and tackle it.

Showing Posts 1 to 10

josevalim

josevalim

Creator of Elixir

There are two challenges in making this practical:

  1. How will your template know that the right side of ← in your comprehensions is state it needs to keep around between renders in order to generate diffs? It is easy to do at the top level (which is what streams do) but hard otherwise. Even if you keep lists in __changed__, how will you diff, for example, a list inside another list? Or a list that is generated by a function or Enum.map?

  2. If the list is large, you will need to traverse the old and new list in order to find which keys were added and removed. And you have to keep the whole old list in memory for this to work. Both problems are solved by streams.

Jskalc

Jskalc OP

Good points, as always.

  1. We only need to compare with previously-rendered list, correct? So I’d say it’s a job of __changed__ to keep that old state around until the render is completed. We don’t need to keep keys for each comprehensions separately, we could calculate old keys based on __changed__. There’s one possible problem though - non-deterministic function used in list comprehension, based on a current state. I wonder how often this could happen? :thinking: I don’t think I ever used such a construct.

  2. Correct me if I’m wrong, but isn’t it a small cost to pay? Currently we always traverse the whole list once and render all the elements underneath. With my suggestion we would need to traverse lists twice (assuming similar size post-update) but at the same time it could possibly skip rendering multiple children elements if they were not updated. Regarding memory, we only need to keep an old version around until render is done, so possibly not a long time. We already do it with maps that might contain lists as well. Also, depending on how exactly GC works, that object might be still around even if we don’t keep it in __changed__. Last thing, an old list and a new one might reuse multiple elements, so it’s not that cost of keeping old list in memory doubles the consumption…

A good question to ask: if it would make rendering 5-10% slower for certain use cases but reduce payload by 90%, is it worth it? I believe the answer is yes :sweat_smile:

josevalim

josevalim

Creator of Elixir
  1. Yes, __changed__ only keeps the list (the assign) but not any transformation you apply on it. You have a good example on depending on other state that may change.

  2. You need to traverse the list twice and build maps based on keys. Then you need to compare the values of the keys: this may not be a cheap operation, to say two values are equal (which may be the majority if the list only changes a little), you need to fully traverse two values. For example, comparing two user structs will compare all fields and all associations recursively.

Plus the memory cost is not the cost of keeping two copies in memory. It is the cost that you need to keep the whole collection in memory after rendering until the next render. While streams can purge everything immediately after render.

Anyway, don’t let me be a party popper. If you feel you can tackle these limitations, go for it!

Jskalc

Jskalc OP

Is that so? I thought __changed__ is kept around between assigns in handle_event etc until render and then discarded, so in most cases a few milliseconds.

Anyway, I think I’m curious enough to try it :wink: I might ask for some guidance later, if you won’t mind.

josevalim

josevalim

Creator of Elixir

You are correct. But in order to have something in __changed__, you need to have the list of the previous render, which is the old state in __changed__. Streams do not require you to do it. In other words, streams allow you to remove the collection from assigns after render. Your approach requires you to keep the collection in assigns (so it goes to __changed__ in the next render).

Feel free to ask and reach out!

Zurga

Zurga

What if you store a hash of the object that is represented by the key? It would not allow you to see how an element in the list has changed, but at least it is better than sending the entire list.

This would reduce the memory footprint only and might add more computational strain.

I’ve had no problems so far storing the associations as an assign after mount/update/handle_* and handling them with Stream. Just have a function that handles setting the assigns.

Having said that, I can understand why OP would want this.

Jskalc

Jskalc OP

I’m aware assign still has to be in memory. For me it’s not a downside - it’s exactly the same case as simply rendering a list comprehension without using streams.

Just to be clear about my goal - I want to optimize HTML diff size. My idea won’t help with memory usage, nor with performance (it might be slightly slower). But I believe it’s still worth a shot since IMO this is the last substantial improvement to be done in that area :+1:

I’m getting familiar with the HEEX engine right now, just real life gets in the way :wink:

josevalim

josevalim

Creator of Elixir

If we are going to ask people to add a :key to their comprehensions, wouldn’t it be better to ask them to use streams? I understand this is simpler to use but if the recommendation will be to prefer streams whenever possible, because they reduce HTML size, perform better, and use less memory, it is hard to argue its inclusion (and that’s not even considering complexity in the implementation).

LostKobrakai

LostKobrakai

I’m not sure I subscribe to the notion that streams are superior on all accounts. Stream work great for what they intend to do, but not everything is modelled to a insert/update/delete event system, which can just be forwarded into streams apis. Once that’s the case by now the suggestion has been “track the keys manually”, at which point I’d argue that no – streams are now no longer the better option to a built in key tracking option.

chrismccord

chrismccord

Creator of Phoenix

I just want to note that key tracking could be added to streams without holding the collection in in memory, at the cost of only holding the bookkeeping keys, so it’s not 1 vs the other in this regard.

Where Next? Top

Trending in Proposals: Ideas Top

Other Trending Topics Top

JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
mcass19
ExRatatui lets you cook up rich terminal UIs in Elixir, powered by Rust’s ratatui via Rustler NIFs. Build interactive terminal applicatio...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge &amp; Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New
ausimian
Emily is an Elixir library that runs Nx computations on Apple’s MLX. Install it as the default Nx backend and Nx, defn, Axon, Nx.Serving,...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews