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

AHBruns
Rethinking phx-no-feedback So, I won’t go into how phx-no-feedback works in detail, but the tl;dr is that it lets you conditionally add a...
New
kccarter
This is likely a feature request unless we’re overlooking something, but it would be a nice improvement to the developer experience if th...
New
tubedude
Hey, Earlier i posted a question, but after some research I think a proposal is due. Working on a Phoenix Live View app, I needed clien...
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
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
mortenlund
Hi! Suggest to add the ability to do use the components socket as input into Phoenix.LiveView.send_update/3 function to make it easier t...
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
Astolfo
When generating a release with phx.gen.release --docker a debian version is used by default to avoid “DNS issues” on Alpine. It seems li...
New
dogweather
Hi Phoenix community! First off, huge thanks for an amazing framework - Phoenix has been a joy to work with. I have a small UX suggestio...
New

Other popular topics Top

Qqwy
Update: How to use the Blogs &amp; Podcasts section You can post links to your blog posts or podcasts either in one of the Official Blog...
3271 127089 1222
New
RisingFromAshes
I’ve read in another post that it may be possible with a router helper - but I couldn’t find an appropriate one, and tbh, I’m still just ...
New
grych
Hi folks, Few months ago I have announced the proof-of-concept of the library to manipulate the browsers DOM objects directly from Elixi...
639 52673 488
New
hariharasudhan94
I would like to know what is the best IDE for elixir development?
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
Darmani72
If I have a post route which an argument: post /my_post_route/:my_param1, MyController.my_post_handler How would get the post params ...
New
AstonJ
Seen any cool LiveView demos, sample apps or examples? Please post them here! :003:
New
klo
Got a question about when to concat vs. prepending items to list then reversing to achieve appending. So i know lists boil down to [1 | ...
New
AstonJ
Posting this to see if we can make things easier for people to get into Neovim. If you use Neovim and have a favourite distro please let ...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement