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
:keyis 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
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?
Would like to refer this in github PR / issue if I’ll be able to sit down and tackle it.
Marked As Solved
steffend
We now have :key for :for in LiveView 1.1! ![]()
Also Liked
steffend
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
I think this is an interesting idea that I would explore right now.
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… ![]()
Popular in Proposals: Ideas
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance










