Update 1:
@steffend posted a complete example of how one could achieve the effect I described leveraging LiveComponent and Streams to keep each node’s data in server memory. Thank you kindly.
Update 2:
I had a few key insights since starting this discussion (and duly apologise for not thinking of it earlier).
-
Lists are not arrays, they merely exhibit some array-like semantics.
Being aware of ways to keep tree data in arrays I’ve been trying to come up with a way to achieve a kind of dual nature version of recursive data whereby it’s a both a (streamable) list and (recursively loaded) struct at the same time i.e. without having to maintain two copies of it. Then it dawned on me that actually lists are not arrays but exactly the recursive structure I’m looking for because the way they are implemented in Elixir to allow for nested lists they already maintain a reference to a parent which is nil for non-nested list heads. -
A key part of the problem is the gigantic and indeterminate size of the recursive underlying data resulting in bloated memory and/or DOM deltas. I know I mentioned it, to myself and in this thread, but didn’t take note of one implied observation until now. My (and likely most) recursive data has two parts - the recursive structure and the data it structures. Compared to the combined node contents, the tree/graph structure is quite small. Seeing that I already employ techniques to limit how much of the large tree is loaded and presented to a user at a time, the structural part of that is amost certainly small enough to keep in memory as a whole to serve the purpose of what Steffen calls “surgical updates”.
-
After combining insights 1) and 2) above, it dawned upon me that if I extract only the (integer, in my case) id’s of the recursive element from the database or loaded structure, it can accurately and succinctly represent the tree strucure in a list, so that
[1,2,[3,4,[5,6,7],8],9]
represents the tree
-1
-2
+3
| -4
| +5
| | -6
| | -7
| -8
-9
[Edit] The non-obivious mapping in the above is that a nested list maintains a “parent” element reference. Semantically a list is [head|tail] where the tail is also a list, so to store a tree in there simply means adding the convention that
By how Elixir’s (linked) lists map onto erlang’s [head|tail] the internal structure in both the above cases are the same. It’s actually list operations themselves and the Enum behaviour that creates the illusion that the recursive structure is linear.
I guess it’s in honour of simplicity that the goto approach for traversing a nested list is to flatten it first, but it’s trivial to write a function which mimics each, map, reduce, fold or fold for raw nested lists. Not that it’s important, just an observation.
The implication of this insight is that I do not need to retain the whole cluster of Ecto structures to remember where everything fits into the tree. A nested list divorced from any Ecto struct metadata can store the recursive structure without any loss of information. It’s trivial to use identifiers from the list representation of the tree to reapply metadata to reload or reconstruct nodes into recursive Ecto struct when that’s needed.
- I probably would, to be explicit and prevent the structural information being discarded by an innocent flatten operation along the line, extract the structure into a list where single integeres represent leaf nodes and nodes with children are represented by a tuple containing the id of the parent element and a list of its children, i.e. the same tree as above would be:
[
1,
2,
{3, [
4,
{5, [
6,
7]
},
8
]
},
9
]
That would leave me with a small enough structure for a TreeView LiveComponent to retain as state for the whole (currently visible) part of the impossibly large tree, directly manipulate cheaply and consult to determine what changes to propagate and what to do with changes propagated from elsewhere. The meat of the node, i.e. cluster of associated schemas can then be sourced and supplied at will as a traditional flat List of such structs or a Map for quick access or a MapSet for quick access and automatic de-duplication, sourced from the database at the most opportune time, i.e either ahead of time with a bg recursive preload which is then flattened for the initial mount, as individual fetches by id if that’s required or by reloading and further preloading a node for which the children were not loaded because of they were too deeply nested for the window at that time.
In summary, I’m currently of opinion that I (and possibly others with the same use case) can prevent the recursive nature of the data from impeding on the regular and sober use of LiveView (with or without Streams) by isolating and removing the structural aspect of the data and explicitly dealing with that data as a separate concern. I’d still need to give both concerns due attention to avoid unwanted behaviour, but they would no longer cripple each other.
If it turns out (I’ve started asking what I hope would be the right questions) that the only or most effective way to get LiveView not replace existing list of children rendered into a parent when the parent is patched is to make the children LiveComponents themselves, then that’s easy and cheap to arrange because the LiveComponent would have minimal state. It would be ideal though if the nodes can be rendered as function components (i.e. no state) and still allow control over when the children should be retained or dumped when the parent node is patched. I’ll continue to investigate if that is feasible.
The same approach would also work for other key types including composite keys, tenanted or partiioned data, the structure would just grow bigger with the additional data per node to uniquely identify it.