I’m writing a pretty basic application where users will be able to create “projects” for data entry purposes. A user can create a “project”, which contains a number of “tables”, where each table has a number of “columns” and columns can be of type choice, in which case each table will have a number of “choices”.
To simplify implementation and because I think that is a genuinely good UI, I want to be able to edit everything in the same page. I have the fields for the project, then I use (a customized version of) to create an editable list of tables, then another <.inputs_for/> to create a list of columns for each table, and then a list of choices for each column.
I expect each project to have 1-2 tables, and each table to have up to 60 columns, with some columns having up to 10-20 choices. For these numbers, the number of elements in the webpage is about 10.000. This creates a 4 levels deep stack of neste inputs, which seems like LiveView can’t optimize in any way. The result is that when I edit any of the hundreds of input components in the form, the changeset is recomputed and a rather large message is sent to Morphdom on the client. The elixir code doesn’t struggle at all with any of this, but Morphdom takes up to 1500ms to patch the updates (I’ve gotten these numbers from logs enabled by liveSocket.enableProfiling();).
Is this the kind of performance one is expected to get? Is there any way I can try to restrict which parts of the HTML dom morphdom actually tries to patch? I could break the tables and columns into different pages (with different liveviews, even), but that is not how I want the page to look like… One of the problem of breaking things up is that you lose the ability to render errors that depend on parts of the form interacting with each other (say we create two tables with the same name: my approach makes it trivial to tag that as an error in the changeset and display it to the user using the normal Phoenix components).
I’m honestly very surprised that morphdom’s performance is so bad on what doesn’t look like a very big page (~10.000 HTML elements, < 200 input elements). It seems like I’ve bought into the hype of thinking that LiveView would scale to moderately sized dynamic webpages, when in fact all examples I see of actual LiveViews are render extremely small web pages… Although Elixir definitely scales up to lots of events, the javascript seems like a serious bottleneck!
A while back I was having a problem with an app of mine similar to this. I was rendering a very large list of small elements, and a few items in that list were changing. This was causing a complete re-render. Someone suggested using LiveView streams, and that did the trick. In the end I wrote a little function to take the before and after lists, and come up with the set of inserts/deletes to the stream. Look at this thread and the function at the end.
Try adding stable HTML id attributes to your table containers and cells. I’m not sure exactly how LiveView will treat this underneath, but it may help both LiveView’s diffing and Morphdom’s DOM reconciliation process, even when dealing with large updates.
To expand on the above answers (which are all valid), the problem you’re running into is that LiveView is not capable of properly diffing collections. So when you do something like <div :for={row <- @rows} />, LiveView has to send down the entire list of rows if a single one is changed.
If you wrap your rows in LiveComponents, LiveView is able to diff them individually, so only one row will be sent when it’s updated. If you need further optimization you could then also wrap the columns in LiveComponents.
The LiveComponents obviously have some overhead but they are in-process so they share memory with the parent LiveView (the assigns won’t be copied).
You can also look into using Streams but I wouldn’t recommend that for this use-case unless you really need to optimize performance. LiveComponents will be enough here, I think.
I did try this, and as I expected I didn’t get any benefit. Having stable IDs doesn’t really make much of a difference because Morphdom has to inspect the children anyway to see if anything has changed. I did look up whether there were any attributes I could use to tell Morphdom that the children of that node didn’t change, but there aren’t. What one could do would be to use (for example) the phx-ignore attribute and set it conditionally if the children haven’t changed. I did not try that yet, as that would require inspecting changesets for changes. And anyway, IDK exactly how to do that if the changeset has already been converted to a form. But I guess looking that up would be the next step.
Was following your long thread about streams and whatnot and I think this was mentioned there too, but is this really true? Because assigns are shared why would there be any additional overhead? Like, there’s a single additional BEAM file created per LiveComponent model, but that isn’t something I would consider “overhead”? Is there something else I’m missing? I curious as I don’t have the best grasp on internals here and want to learn. I’ve always found way the docs are worded to be a bit confusing as well: To paraphrase: “Don’t pass assigns wholesale because they are all passed even if some aren’t used but also it doesn’t matter because memory is shared” It makes sense from a maintainability standpoint but not from a performance one (again, unless I’m wrong).
Hm… It turns out this doesn’t seem to work because if we set the phx-update="ignore" attribute at any point, conditional on the fact that the changeset for that “subform” doesn’t have any changes, Phoenix will keep ignoring it when the subform does have changes in the future.
Well first you may have noticed that I’m essentially borrowing that line from the docs
But in a general sense, any code has overhead. What I (and the docs) am trying to clarify is that LiveComponents have much less overhead than embedding a child LiveView, which is what most people probably think LiveComponents are when they see the name.
Elixir is functional (immutable) and data structures like maps are essentially what are called “persistent data structures”, where the old version of the map stays around somewhere and the new version borrows from it. Since everything is immutable this is easier in some ways and harder in others. I’m not an erlang core dev obviously (and there are some lurking around on here), so I don’t want to speak past my expertise. But the point I’m making is:
If you have a map %{foo: "bar", hello: "world"} and then you call Map.put(...) on it, you get back a new map. But the contents of the old map are not (necessarily) copied, they’re usually just kept around as references. In practice this is more complicated, obviously.
That doesn’t mean the new map has no overhead, but it’s less than you might naively think. When you pass your data around to all of the LiveComponents the same thing is happening, and so there is a bit of overhead (keeping track of all those assigns maps and so on), but it’s not literally duplicating everything.
Of course, if you used a child LiveView (which has its own process), then you would be literally duplicating everything.
I have read some of the code recently (I think you know why…), but I don’t have a perfect grasp on it either. But essentially, there is some bookkeeping going on - have a look at diff.ex and channel.ex and look for LiveComponent bits, that’s where I found some of it.
There is also some extra code to handle them on the client. I found it but I’ve already forgotten where…
You are essentially traveling down the chain of thought that led to Streams. I’d strongly recommend trying the LiveComponent strategy first, and then look into Streams in the future if you feel the need (you probably won’t).
Ha yes, I figured you just may know a little more. I sorta remember someone asking the different between a function component and a LiveComponent from a memory standpoint and José saying there is essentially no difference but I can’t find the thread so I very well have misread that. The other stuff I understand, obviously much less overhead than a nested LiveView and I do understand persistent data structures so ya you’re right, that is still some overhead for the “pointer” (I know it’s slightly more complex than that). Though now that I think about it, I did remember a comment (that I also can’t find) where someone was saying that smaller maps are actually fully copied but no idea if that’s true, so basically I’m just piling up all these things I read that challenge what I already thought and decided you were the unlucky one I would ask to clarify
The solution to my problem is that I should just read The BEAM Book.
I think the simplest way to explain the difference is that LiveComponents maintain diffs of their assigns separately, while function components do not. In other words, function components (I believe) lack the :__changed__ assign which stores the diffs.
Obviously the overhead of this is to store things which are already in-memory, so it’s just the overhead of another map (and we’re back to square one here).
They are also treated differently by the diffing algorithm and on the wire. I don’t know exactly how it works but the impression I got skimming the code is that they’re treated on the client as if they are a root LiveView, so the morphdom invocation for each LiveComponent is separate from the main tree. Or something, not sure.
The fact that they are diffed separately is very important though, because it means the diff can just be %{id => diff} on the wire, without having to worry about where in the DOM that id actually is, because it’s “globally” unique (per page). So there’s a speedup from that too.
I’m sure a maintainer could give you a much more thorough explanation.
I wouldn’t be surprised, there’s always some overhead with keeping references and a point where the tradeoff isn’t worth it. Only maps above (I believe) 32 entries are stored as hash tables (really hash array-mapped tries, a mouthful).
Important to keep in mind, though, that just because the map is copied doesn’t mean its contents are! If you had a large map in a small map and duplicated the small map, the large map would still just be a reference (I assume).
Of course this is made even more confusing by reference-counted binaries, which aren’t copied even across processes…
I did replace some of the HTML by live components and performance is now much better (patch times ~80-220ms instead of 1500ms), but the delay attributable to the DOM patches (according to the timings given by liveSocket.enableProfiling();) is still noticeable. I’m stil a bit incredulous on why my multi-GHz PC takes so long to patch the DOM. I know I’m not being very smart in the change tracking, but still, it’s disappointing.
I’m seriously thinking of using a normal JS framework instead of LiveView. The only reason I haven’t done it yet is the fact that it would make it harder to use ecto changesets for validation (I’d have to convert the ecto chnagesets to JS somehow, and I’m not in the mood for that).
In any case, I think this problem needs a bit more publicity. I might contribute to the docs warning users of the Morphdom troubles. There are only about 3-4 places on the internet that discuss LiveView’s client-side problems (a youtube video and a couple posts on this forum). Everywhere else sells LiveView as something which will not cause problems for real-world pages, and the main discussion is always centered on the performance impact on the elixir side (“be careful about having too many PubSub messages”, or “be careful about not storing too much state on the server”). But reading the docs I’d never dream about having problems with a webpage with a couple thousands of HTML elements. Worse, it took me 2 days to think of DOM patching as the cause of the performance issues. This speaks more to my ignorance about frontend stuff, but I think the docs should include a warning somewhere.
Giving the situation a more sober look, it seems like even in the worst case (1500ms for 10,000 elements) Morphdom is taking 0.1ms per HTML element. Is that a low? IDK, JS is an interpreted language and the DOM is a big, complex object. So, maybe, shame on me for being surprised…
I feel your pain. However, I am not sure switching to a JS framework will help. Like you said, the bottleneck in in MorphDOM. i’d assume letting the JS part to work more would only make it slower.
That is still very slow. Could you take a look at the socket in devtools and tell us how large the diffs coming over the wire are? I would guess it’s in the MB?
You need to shrink the diffs further. Which parts of the table did you convert to LiveComponents? If it was just the rows and they’re still very large, try wrapping the columns too.
I’m not here to tell you what to do (I am a bigger proponent of client-side rendering than you might expect), but before you head down that path you may want to try searching for the keywords “react table slow”.
Diffing algorithms are not magic, they don’t handle huge amounts of data well.
If the table is large enough you will need to virtualize it (you would have to do this with React too).
They’re rather big, yeah: from 33kB (if I only change the text inside an input component) to 123kB (when I reorder stuff in the list).
I’m aware that diffing algorithms are not magic, but my hope is that in JS I can have more control over the events I emmit and which parts of the tree I actually need to re-render. But again, I have to think a lot about this.
I can make some trivial UI changes in order to decrease the depth of the components (instead of a LiveView per project I can have a LiveView per table, which is the fundamental unit the user will be working in) and I believe most tables will have < 20-30 columns. I start getting bad times when I reach the 60-80 columns. But I’m always nervous about having these limits (if I expect a size of 20, I’d rather have the UI allow for 200 as a safety margin).
In LiveView the way you control this is with LiveComponents. If only one component changes, then only that component will be re-rendered.
Like I said, if you’re doing this: <div :for={col <- @cols}>{col.text}</div>, and you have 60 columns, all 60 columns will be sent when one changes. If your columns have a lot of dynamic content then this could be expensive. I will say though that 100ms to diff 60 columns still doesn’t sound right to me, so there might be something else wrong. You should inspect the diffs more carefully and see if something else is leaking through that you’ve overlooked.
This is a sign that something is wrong, because re-ordering LiveComponents should be effectively free (on the order of maybe a couple hundred bytes).
Thanks for the response, this is digging much more into stuff I don’t know. I don’t want to address too much as it’s a bit of the main thread, but yes, this all makes sense for the most part. Wasn’t even considering :__changed__
To my mind, what the comment I read was saying was saying that it was a full copy. Maybe it wasn’t though? I need to read up a little bit more in order to respond even semi-intelligently.