Search code examples
javascriptknockout.jsknockout-3.0

Why does knockout template binding stop working after manually reordering - and reverting - dom items?


I am using a knockout foreach (more specifically, template: { foreach: items }) binding to display a list of elements. I then proceed to take the following actions:

  1. Swap the first and second elements of the observable array. I see the changes reflected on screen, as expected.
  2. Repeat the previous action to revert to the initial state. Again, this works as expected.
  3. Now, swap the first and second DOM elements. I see the changes reflected on screen, as expected.
  4. Repeat the previous action to revert to the initial state. Again, this works as expected.

Even though we have manually tampered with the DOM, we have reverted to exactly the initial state, without invoking knockout during the DOM tampering. This means the state is restored to the last time knockout was aware of it, so it should look to knockout as if nothing ever changed to begin with. However, if I perform the first action again, that is, swap the first two elements in the array, the changes are not reflected on screen.

Here is a jsfiddle to illustrate the problem: https://jsfiddle.net/k7u5wep9/.

I know that manually tampering with the DOM managed by knockout is a bad idea and that it can lead to undefined behaviour. This is unfortunately unavoidable in my situation due to third party code. What stumps me is that, even after reverting the manual edits to the exact initial state, knockout still does not work as expected.

My question is: what causes this behaviour? And then, how does one work around it?


Solution

  • Turns out there is nothing magical happening here. The mistake I made was to only consider elements instead of all nodes. The knockout template binding keeps a record of all nodes when reordering, not just elements.

    Before manually editing the DOM, the child nodes of the template binding are:

    NodeList(6) [text, div, text, text, div, text].

    After manually swapping the first two elements using parent.insertBefore(parent.children[1], parent.children[0]), this turns into:

    NodeList(6) [text, div, div, text, text, text].

    Repeating the action yields:

    NodeList(6) [text, div, div, text, text, text].

    Although this is identical to the initial state when only referring to elements, it is quite different when referring to all nodes.

    The solution now becomes clear. One way to perform a proper manual swap is to replace

    parent.insertBefore(parent.children[1], parent.children[0]);

    with

    let nexts = [parent.children[0].nextSibling, parent.children[1].nextSibling];
    parent.insertBefore(parent.children[1], nexts[0]);
    parent.insertBefore(parent.children[0], nexts[1]);
    

    as seen in https://jsfiddle.net/k7u5wep9/2/.

    Obviously more care has to be taken when there are no text nodes before/after, but the idea remains the same.