You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Reconciliation makes more moves than necessary? #212
Originally posted by mindplay-dk April 6, 2021
I've been interested in the reconciliation algo for a while, and I'm sort of an idiot when it comes to this stuff - it's really difficult for me to understand the reconciliation code in most of the libraries I've looked at, and frankly I didn't really trust that any of them were doing it optimally.
So I set up a use-case driven test bench with a bunch of console.log statements, so I can actually try various operations on a list, and see what sort of DOM operations result from those.
Here it is, first for Sinuous - with diff.js from the map module:
Here, I noticed that "Rotate Up" moves 3 elements, while "Rotate Down" moves 2 elements - in either case, one move should be enough, since it's just the first/last element moving to the opposite end of the range.
I understand your code is derived from udomdiff? So here's the same test-bench for udomdiff:
Here, "Rotate Up" takes two moves (one of them a replaceNode) which is still one too many - while "Rotate Down" does it accurately, in a single move.
As said, I am an idiot at this stuff, but I decided to try my own naive approach - I simply remove old elements, append new elements, and then take two passes: first, I move only the nodes where moving them isn't going to break the order somewhere else - and then, I take a second pass, moving all remaining nodes that aren't already in the right place.
Here, "Rotate Up" and "Rotate Down" both finish in one move.
As said, this approach is totally naive, and probably isn't blazing fast, since I'm using a two passes (and lots of comparisons with next/previous siblings) but I wanted something simple enough that I could understand it myself, being (as stated) an idiot.
Apparently, I'm not a complete idiot, because it works. 😂
I expect this simple algo could be optimized as well, because for most operations (all but "Shuffle") it doesn't actually need to do anything in the second pass - there's only one difference between the first and second pass, and it's this:
So, basically, if that second expression is ever false during the first pass, you can return before the second pass, because it would be fully identical to the first pass.
By the way, don't ask me why these loops are backwards. I've no idea if they need to be. I was just trying stuff, and it ended up this way, and it seems to work, so I didn't question it. (Might be good for micro perf anyway.)
I haven't benchmarked or optimized this at all, just throwing it up here for discussion.
well, since map even crashes my application and I don't want to analyze code which may be replaced soon, I'll try and implement the abovementioned approach myself (especially, since I need a fix now or I'll have to proceed to another library/framework with a working list renderer)
As mentioned yesterday, I needed a replacement for the original map implementation as that crashed my application.
For that reason, I tried to understand the internals of sinuous and its way of creating and destroying HTML nodes and took the ideas from #168 to implement it anew.
I made two versions
a trivial one which simply asserts that already existing nodes (with any focus, selections, drop-down states etc.) are kept whenever possible, and
one based on the "snabbdom" algorithm (without node creation and removal - as that is done outside)
Both versions have the following restrictions:
list items must be objects
the list item rendering function should always create exactly one DOM node per item
You will find the sources below. Both versions have passed some "smoke" tests (and do not crash, at least) but have not yet been tested for efficiency
import{api}from'sinuous'/** * Map over a list of items that create DOM nodes. * * @param {Function} items - Function or observable that creates a list. * @param {Function} expr * @param {boolean} [cleaning] * @return {DocumentFragment} */exportfunctionmap(ListFn,ItemRenderer,cleaning){const{ subscribe, root, sample, cleanup }=apiif(cleaning==null){// disable cleaning for templates by defaultcleaning=!ItemRenderer.$t}constDisposers=newMap()letParent=document.createDocumentFragment()constNodeAfterList=add(Parent,'')constunsubscribe=subscribe((oldItemList)=>{constnewItemList=ListFn()returnsample(()=>updateInPlace(oldItemList||[],newItemList||[],NodeAfterList,createFn,cleaning&&dispose))})cleanup(unsubscribe)cleanup(disposeAll)/**** createFn ****/functioncreateFn(Parent,ListItem,ListIndex,List,FollowUpNode){if(cleaning){returnroot((Disposer)=>{constNode=add(Parent,ItemRenderer(ListItem,ListIndex,List),FollowUpNode)Disposers.set(Node,Disposer)returnNode})}else{returnadd(Parent,ItemRenderer(ListItem,ListIndex,List),FollowUpNode)}}/**** dispose ****/functiondispose(Node){letDisposer=Disposers.get(Node)if(Disposer!=null){Disposer()}Disposers.delete(Node)}/**** disposeAll ****/functiondisposeAll(){Disposers.forEach((Disposer)=>Disposer())Disposers.clear()}returnParent}/**** updateInPlace - to keep focus, drop-downs etc. intact ****/functionupdateInPlace(oldItemList,newItemList,NodeAfterList,createFn,dispose){constParent=NodeAfterList.parentNode/**** allow for quick lookup of new list items ****/letnewItemSet=newSet()newItemList.forEach((Item)=>newItemSet.add(Item))/**** associate existing HTML nodes with their list items ****/letoldNodeMap=newMap(),oldNode=NodeAfterListfor(leti=oldItemList.length-1;i>=0;i--){constItem=oldItemList[i]oldNodeMap.set(Item,oldNode=oldNode.previousSibling)}/**** remove no longer needed HTML nodes ****/oldItemList.forEach((Item)=>{if(!newItemSet.has(Item)){constoldNode=oldNodeMap.get(Item)Parent.removeChild(oldNode)if(dispose!=null){dispose(oldNode)}}})/**** now reorder existing HTML node or create new ones ****/letFollowUpNode=NodeAfterListfor(leti=newItemList.length-1;i>=0;i--){constItem=newItemList[i]letNode=oldNodeMap.get(Item)if(Node==null){Node=createFn(Parent,Item,i,newItemList,FollowUpNode)}else{Parent.insertBefore(Node,FollowUpNode)}FollowUpNode=Node}/**** that's it! ****/returnnewItemList.slice()}/**** add (has been taken from former map "utils") ****/constGROUPING='__g';letgroupCounter=0;functionadd(parent,value,endMark){letmark;if(typeofvalue==='string'){value=document.createTextNode(value);}elseif(!(valueinstanceofNode)){// Passing an empty array creates a DocumentFragment.value=api.h([],value);}if(value.nodeType===11&&(mark=value.firstChild)&&mark!==value.lastChild){mark[GROUPING]=value.lastChild[GROUPING]=++groupCounter;}// If endMark is `null`, value will be added to the end of the list.parent.insertBefore(value,endMark);// Explicit undefined to store if frag.firstChild is null.returnmark||value;}
and here is the one based on the "snabbdom" algorithm:
import{api}from'sinuous'/** * Map over a list of items that create DOM nodes. * * @param {Function} items - Function or observable that creates a list. * @param {Function} expr * @param {boolean} [cleaning] * @return {DocumentFragment} */exportfunctionmap(ListFn,ItemRenderer,cleaning){const{ subscribe, root, sample, cleanup }=apiif(cleaning==null){// disable cleaning for templates by defaultcleaning=!ItemRenderer.$t}constDisposers=newMap()letParent=document.createDocumentFragment()constNodeAfterList=add(Parent,'')constunsubscribe=subscribe((oldItemList)=>{constnewItemList=ListFn()returnsample(()=>updateInPlace(oldItemList||[],newItemList||[],NodeAfterList,createFn,cleaning&&dispose))})cleanup(unsubscribe)cleanup(disposeAll)/**** createFn ****/functioncreateFn(Parent,ListItem,ListIndex,List,FollowUpNode){if(cleaning){returnroot((Disposer)=>{constNode=add(Parent,ItemRenderer(ListItem,ListIndex,List),FollowUpNode)Disposers.set(Node,Disposer)returnNode})}else{returnadd(Parent,ItemRenderer(ListItem,ListIndex,List),FollowUpNode)}}/**** dispose ****/functiondispose(Node){letDisposer=Disposers.get(Node)if(Disposer!=null){Disposer()}Disposers.delete(Node)}/**** disposeAll ****/functiondisposeAll(){Disposers.forEach((Disposer)=>Disposer())Disposers.clear()}returnParent}/**** updateInPlace - to keep focus, drop-downs etc. intact ****/functionupdateInPlace(oldItemList,newItemList,NodeAfterList,createFn,dispose){constParent=NodeAfterList.parentNode/**** allow for quick lookup of new list items ****/letnewItemSet=newSet()newItemList.forEach((Item)=>newItemSet.add(Item))/**** associate existing HTML nodes with their list items ****/letoldNodeMap=newMap(),oldNode=NodeAfterListfor(leti=oldItemList.length-1;i>=0;i--){constItem=oldItemList[i]oldNodeMap.set(Item,oldNode=oldNode.previousSibling)}/**** remove no longer needed HTML nodes and collect all others ****/letoldNodeList=[]// the "snabbdom" algorithm needs this listoldItemList.forEach((Item)=>{constoldNode=oldNodeMap.get(Item)if(newItemSet.has(Item)){oldNodeList.push(oldNode)}else{Parent.removeChild(oldNode)if(dispose!=null){dispose(oldNode)}}})/**** create a list of (already existing) HTML nodes for the new item list ****/letnewNodeList=[]// the "snabbdom" algorithm needs this list as wellnewItemList.forEach((Item,Index)=>{letoldNode=oldNodeMap.get(Item)if(oldNode!=null){newNodeList.push(oldNode)}})/**** now bring the existing HTML nodes in their proper order ****/reorder(Parent,oldNodeList,newNodeList,NodeAfterList)/**** finally add nodes for any new items ****/letFollowUpNode=NodeAfterListfor(leti=newItemList.length-1;i>=0;i--){constItem=newItemList[i]letNode=oldNodeMap.get(Item)if(Node==null){Node=createFn(Parent,Item,i,newItemList,FollowUpNode)}FollowUpNode=Node}/**** that's it! ****/returnnewItemList.slice()}/**** reorder ****/// originally taken from taken from https://github.com/luwes/js-diff-benchmark/blob/master/libs/snabbdom.js// but without node removal (which has already been done before)functionreorder(parent,a,b,afterNode){consta_index=newMap();constb_index=newMap();letneed_indices;letstart_i=0;letend_i=a.length-1;letstart_j=0;letend_j=b.length-1;letstart_a=a[start_i];letend_a=a[end_i];letstart_b=b[start_j];letend_b=b[end_j];letold_start_j;letnew_start_i;while(start_i<=end_i&&start_j<=end_j){if(start_a==null){start_a=a[++start_i];}elseif(end_a==null){end_a=a[--end_i];}elseif(start_b==null){start_b=b[++start_j];}elseif(end_b==null){end_b=b[--end_j];}elseif(start_a===start_b){start_a=a[++start_i];start_b=b[++start_j];}elseif(end_a===end_b){end_a=a[--end_i];end_b=b[--end_j];}elseif(start_a===end_b){parent.insertBefore(a[start_i],a[end_i].nextSibling||afterNode);start_a=a[++start_i];end_b=b[--end_j];}elseif(end_a===start_b){parent.insertBefore(a[end_i],a[start_i]||afterNode);end_a=a[--end_i];start_b=b[++start_j];}else{leti;// Lazily build maps here. They are relevant only if there has been// a move, or a mid-list insertion or deletion and not if there// has been an insertion at the end or deletion from the front.if(!need_indices){need_indices=true;// Create a mapping from keys to their position in the old listfor(i=0;i<a.length;i++){a_index.set(a[i],i);}// Create a mapping from keys to their position in the new listfor(i=0;i<b.length;i++){b_index.set(b[i],i);}}old_start_j=a_index.get(start_b);new_start_i=b_index.get(start_a);// Insertionif(old_start_j===undefined){parent.insertBefore(start_b,a[start_i]||afterNode);start_b=b[++start_j];}// Moveelse{parent.insertBefore(start_b,a[start_i]||afterNode);a[old_start_j]=null;start_b=b[++start_j];}}}if(start_i<=end_i||start_j<=end_j){if(start_i>end_i){// old list exhausted; process new list additionsfor(start_j;start_j<=end_j;start_b=b[++start_j]){parent.insertBefore(start_b,a[start_i]||afterNode);}}}returnb;}/**** add (has been taken from former map "utils") ****/constGROUPING='__g';letgroupCounter=0;functionadd(parent,value,endMark){letmark;if(typeofvalue==='string'){value=document.createTextNode(value);}elseif(!(valueinstanceofNode)){// Passing an empty array creates a DocumentFragment.value=api.h([],value);}if(value.nodeType===11&&(mark=value.firstChild)&&mark!==value.lastChild){mark[GROUPING]=value.lastChild[GROUPING]=++groupCounter;}// If endMark is `null`, value will be added to the end of the list.parent.insertBefore(value,endMark);// Explicit undefined to store if frag.firstChild is null.returnmark||value;}
Nota bene: the // Insertion and the process new list additions sections could probably also be removed from the reorder function in the snabbdom variant - but I did not test that yet
Activity
rozek commentedon Jan 30, 2023
is there any plan when to fix
map?rozek commentedon Feb 1, 2023
well, since
mapeven crashes my application and I don't want to analyze code which may be replaced soon, I'll try and implement the abovementioned approach myself (especially, since I need a fix now or I'll have to proceed to another library/framework with a working list renderer)Give me time til tomorrow...
rozek commentedon Feb 2, 2023
Good evening!
As mentioned yesterday, I needed a replacement for the original
mapimplementation as that crashed my application.For that reason, I tried to understand the internals of sinuous and its way of creating and destroying HTML nodes and took the ideas from #168 to implement it anew.
I made two versions
Both versions have the following restrictions:
You will find the sources below. Both versions have passed some "smoke" tests (and do not crash, at least) but have not yet been tested for efficiency
rozek commentedon Feb 2, 2023
Here is the "trivial"
mapimplementation:rozek commentedon Feb 2, 2023
and here is the one based on the "snabbdom" algorithm:
rozek commentedon Feb 4, 2023
Nota bene: the
// Insertionand theprocess new list additionssections could probably also be removed from thereorderfunction in thesnabbdomvariant - but I did not test that yet