Jskalc

Jskalc

Possible payload size improvement to HEEX list comprehensions

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.

Marked As Solved

steffend

steffend

Phoenix Core Team

Also Liked

steffend

steffend

Phoenix Core Team

There’s a new branch for you to try out: https://github.com/phoenixframework/phoenix_live_view/pull/3865

The new code only sends a position when it changed or moved.

Also, the new branch does not use live components any more, so the keys are all local.

This also adjusts all other comprehensions to be implicitly keyed by their index, so nested change tracking also applies whenever you omit the key, which is useful for things like slots.

josevalim

josevalim

Creator of Elixir

I think this is an interesting idea that I would explore right now.

garrison

garrison

If this code is going to be refactored heavily at some point I would like to quickly throw my hat in the ring and argue that LiveComponents (or similar) should be diffed with local keys rather than global keys, because (as I showed a couple posts up) global keys do not compose properly and cause problems. I have even managed to run into collisions a couple times in my own apps, where I 100% control the keys, and it can only get worse from there.

I have read some of the LV code, but I don’t have near the understanding needed to know how hard this would be. IIRC you are the original author of the LiveComponent feature, so maybe you can shed some light there. (Though TBH I don’t usually remember how my own code works after even a few months lol)

I’m not sure how you would handle this:

def render(assigns) do
  ~H"""
  <div>
    <div>
      <.user :for={user <- @some_users} :key={user.id} />
    </div>
    <div>
      <.item :for={item <- @items} :key={item.id} />
    </div>
  </div>
  """
end

If there are multiple comprehensions in a component, you can’t get away with having one node in the tree per component. The tree structure has to match the dom node structure because only a node is guaranteed to have a single list of children.

Given that HEEX splits components into statics/dynamics, I have a sneaking suspicion accounting is all done at the component level, although there are probably structures to deal with comprehensions and conditionals, right? Maybe those could be modified to do the job, I’m not sure.

Conditionals are easy as long as they return the same number of children (nil is a valid child node). Comprehensions are what necessitate the :keys to keep the diff fast. I think React mounts fibers for every node (not just components) because I don’t see how else they would deal with this, but strangely I have been unable to find any mention of that fact anywhere. I’ll probably have to look at the code to know for sure.

But yeah, I worry this would turn into “rewrite the entire LiveView engine”. Which would probably be worth it TBH, but that’s a lot easier to say when you’re not the one doing it… :slight_smile:

Where Next?

Popular in Proposals: Ideas Top

GregPhx
Greetings Everyone!!! A little bit of my background so it could be easier to understand where my comments are coming from, and to take t...
New
MUSTDOS
Hello all! assign(socket, :name, “Elixir”) Why can’t we have assign/stream take a group of atoms and maps/structs as a default to r...
New
pinetops
LiveView is by far my favorite web tech, but a few things have been nagging me. So with all the fancy and ill advised elixir tricks I cou...
New
pelopo
Hi, Oficial docs got this wonderful feature to download the ePub version that we can chuck to our kindles and read it. Now we are in the...
New
cevado
IEx is a very powerfull shell and it would be awesome to have all this power integrated inside a code editor. Clojure enables something l...
New
pejrich
I propose adding compact_map/2 to the Enum module. What is it? Sometimes you want to map over a collection, but sometimes you want to ma...
New
BartOtten
I’d like to propose that we refrain from using the term "DeadView" as the opposite of “LiveView” and instead choose an alternative. A new...
New
dkuku
This is a proposal to make the map key mismatch errors a bit better: Every time I have a typo It’s very challenging for me even when I u...
New
byhemechi
Many web frameworks (e.g. Remix, Gatsby) have an option for their link components that begins the navigation request on hover so that whe...
New
sodapopcan
So after complaining about this for the third or fourth time on this forum, I figured I should make a proposal. TL;DR with can be hard t...
New

Other popular topics Top

senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
albydarned
Hello all! I am typing this post from my new MacBook Pro with the M1 chip. I’m loving it so far, and will probably use it as my daily dr...
New
chrismccord
As promised, the first release candidate of Phoenix 1.3.0 is out! This release focuses on code generators with improved project structure...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
chrismccord
Phoenix 1.4.0 released Phoenix 1.4 is out! This release ships with exciting new features, most notably with HTTP2 support, improved deve...
688 30877 112
New
jerry
Good day to you all. I have been struggling to get a query involving like and ilike to work. Can anyone assist me on this, please? pro...
New
jaysoifer
Is there a way to rollback a specific migration and only that one (“skipping” all the other ones)? Would mix ecto.rollback -v 200809061...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
sergio
Kind of like when jquery came out, it was super necessary. Existing drag and drop libraries have a bunch of baggage to support old browse...
New
vonH
In asking this question I am more interested about the expressiveness of the language itself and less concerned about the availability of...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement