snabbdom / snabbdom

A virtual DOM library with focus on simplicity, modularity, powerful features and performance.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Virtualized list issue

tsunamiq opened this issue · comments

I'm trying to build out a virtualize list within a table and am running into rendering issues. Following a standard pattern of removing and adding rows when scrolling through a list on a table. Scroll down, remove nodes from the top and add rows to the bottom while keeping all indexes up to date with the local state of the entire list.

issue I'm running into is when I scroll down I'm finding that the entire list re-rerenders after rows are removed from the top of the list. When scrolling up there is no issue: rows are added to the top of the list and removes from the bottom with existing rows not re-rerendered.

I've narrowed down this issue to this code block:

api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);

deleted rows are pushed down in the dom until they reach the bottom and are removed. This causes all rows to re-render. I believe this is the case. Is it possible to verify and possibly propose a solution to avoid re-rendering all untouched rows when removed top rows/nodes?

Thanks for reporting this and digging into the code to find the cause. I think your diagnosis is correct. It seems that a removed element early in the list together with an added element late in the list is not handled very well.

When you say that the entire list "re-renders" what do you mean? Is this something you observe as degraded performance, with devtools, or something else?

To fix this it would be helpful to have a small test case demonstrating the problem. Would it be possible for you to put together a small example where the problem is evident?

This was observed in chrome devtools. I'm working with a table and I see the 's rerender in devtools (the html mark up flashes) and on screen (flickers) even when there is no changes to the actual rows. the only change is that the new list removes rows from the top and adds to the bottom.

There is an interesting work around that solves this issue by breaking up the render into two events.

  1. Remove the two rows at the top of the list and render
  2. then add rows to the bottom of the list and render.

removing only the top rows, I believe, forces logic to execute the oldVnodeBottom === newVnodeBottom logic which avoids the re-rendering of existing rows. adding rows to the bottom does not cause issues. Looking into building an example for you.

here is a demo of the issue. open debugger and view list. remove items from top and you'll see all rows re-render. add rows to the bottom and only the bottom row updates. codesandbox

Thanks for the additional information.

There is an interesting work around that solves this issue by breaking up the render into two events.

  1. Remove the two rows at the top of the list and render

  2. then add rows to the bottom of the list and render.

That makes sense given how I understand the issue. It's only when removing in the beginning and adding in the end at the exact same time that the case that is not handled correctly occurs.

here is a demo of the issue. open debugger and view list. remove items from top and you'll see all rows re-render. add rows to the bottom and only the bottom row updates.

I don't think keys are set up correctly in that example? I think { attrs: { key: item.key } } should be { key: item.key }. Additionally when the vnodes that are being patched against are created from toVNode they won't have a key set either.

In that example, elements aren't added and removed at the same time, so I don't think it should expose the bug (unless there is a much bigger bug).

Are you interested in submitting a PR with a test case and a fix? Alternatively, I can take a look at this — maybe in the weekend. My initial idea would be to try and maintain symmetry in how the patching is done. Right now adding at the beginning and removing at the end is handled as it should be, so the symmetric thing should be done in the other case.

Sorry that I am currently a bit absent wrt maintenance, I am currently busy writing a conference paper. But this particular issue tickled my brain a bit and I remembered that I found a bug in snabbdom during the cyclejs dom driver rewrite that might be related. I totally forgot to open an issue about it and AFAIK it is not fixed yet. The snabbdom test I distilled the bug down to back then is this:

it.only("calls module hook with correct elm when replacing nodes", function() {
  let patches = 0;
  let called = false;
  let child: any;
  const patch = init([
    {
      pre: () => patches++,
      create: (_, vnode) => {
        if(vnode.sel === "h1") {
          if (patches === 1) {
            child = vnode.elm;
          }
        }
      },
      destroy: vnode => {
        if (patches === 1) {
          assert.fail("should not destroy node in first patch");
        } else if (patches === 2) {
          if (vnode.sel === "h1") {
            called = true;
            assert.strictEqual(child, vnode.elm); // This check fails
          }
        }
      }
    }
  ]);

  const childVdom = h("h1", ["This is text"]);
  const vnode1 = h("div", [
    h("span.replace", { key: "key1" }, [childVdom, "1"])
  ]);
  patch(vnode0, vnode1);
  const vnode2 = h("div", [
    h("span.replace", { key: "key2" }, [childVdom, "2"])
  ]);
  patch(vnode1, vnode2);

  assert.strictEqual(called, true);
});

My investigation back then told me that snabbdom first inserts new elements into a list and then removes the old ones which should be flipped.

As said, not sure if this is the same issue, but I quickly ran this test again and it is definitely still an issue.

Sorry that I am currently a bit absent wrt maintenance, I am currently busy writing a conference paper.

No worries and good luck with your paper 👍 💪

Is it intentional that childVdom is reused between the two patches? Because that's not really supported as far as I recall.

No worries and good luck with your paper 👍 💪

Thanks :)

Is it intentional that childVdom is reused between the two patches? Because that's not really supported as far as I recall.

I think so yes. I know that vnode reuse in general is not supported, but I still wanted to fix this as this is very localized (IIRC the change is contained in a single for loop). But yeah, probably this is a different issue then

I see. Thanks for sharing that test case. I think it is a different issue, but good to keep in mind when looking at this one.

Thanks for the additional information.

There is an interesting work around that solves this issue by breaking up the render into two events.

  1. Remove the two rows at the top of the list and render
  2. then add rows to the bottom of the list and render.

That makes sense given how I understand the issue. It's only when removing in the beginning and adding in the end at the exact same time that the case that is not handled correctly occurs.

here is a demo of the issue. open debugger and view list. remove items from top and you'll see all rows re-render. add rows to the bottom and only the bottom row updates.

I don't think keys are set up correctly in that example? I think { attrs: { key: item.key } } should be { key: item.key }. Additionally when the vnodes that are being patched against are created from toVNode they won't have a key set either.

In that example, elements aren't added and removed at the same time, so I don't think it should expose the bug (unless there is a much bigger bug).

Are you interested in submitting a PR with a test case and a fix? Alternatively, I can take a look at this — maybe in the weekend. My initial idea would be to try and maintain symmetry in how the patching is done. Right now adding at the beginning and removing at the end is handled as it should be, so the symmetric thing should be done in the other case.

Hi @paldepind, for the example above, how would we convert an element to a vnode with keys so the issues can be replicated? I can look at it before the weekend but if I don't have solution by then it'd be great if you had some time to look into it.

@paldepind heads up. I believe this will fix the issue but I'm currently running into issue running test and committing locally. The fix is to check if the oldStartVnode is found in the new Vnodes. If not, remove it from the dom and increment oldStartIndex. I don't think this will fix the use case when the new Vnodes are not in the same order as the oldVnodes. so it does fix my issue but not out of ordered lists. The later will have the same behavior as the original code.

 if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToIdx(oldCh, oldStartIdx, oldEndIdx);
}
if (newKeyToIdx === undefined) {
newKeyToIdx = createKeyToIdx(newCh, newStartIdx, newEndIdx);
}

    idxInOld = oldKeyToIdx[newStartVnode.key as string];
    idxInNew = newKeyToIdx[oldStartVnode.key as string];

    // checks if the startVNode is in the new keyToIndex map and if is not found remove from dom.
    if (isUndef(idxInNew)) {
      const ch = oldCh[oldStartIdx];
      api.removeChild(parentElm, ch.elm!);
      oldStartVnode = oldCh[++oldStartIdx];
    } else {
      if (isUndef(idxInOld)) {
        // New element
        api.insertBefore(
          parentElm,
          createElm(newStartVnode, insertedVnodeQueue),
          oldStartVnode.elm!
        );
      } else {
        elmToMove = oldCh[idxInOld];
        if (elmToMove.sel !== newStartVnode.sel) {
          api.insertBefore(
            parentElm,
            createElm(newStartVnode, insertedVnodeQueue),
            oldStartVnode.elm!
          );
        } else {
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
          oldCh[idxInOld] = undefined as any;
          api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
        }
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }

Hi @paldepind, for the example above, how would we convert an element to a vnode with keys so the issues can be replicated?

The easiest thing would be to reuse the previous vnode tree again instead of using toVNode :)

I believe this will fix the issue but I'm currently running into issue running test and committing locally.

Let us know if you need help with that. Thanks for sharing the fix, I'll look more into this over the weekend.

Please check if #1106 fixes the problem in your case :)

@paldepind your solution looks great! very clean and I really like the bottom up approach. It'll definitely work for us. Thank you! you're the 🐐

You're welcome and thanks for the kind words :)

This optimization/fix is included in the new 3.6.2 release.