diff --git a/src/diff/children.js b/src/diff/children.js
index a0812bd1a8..ede1f9ebbe 100644
--- a/src/diff/children.js
+++ b/src/diff/children.js
@@ -92,8 +92,9 @@ export function diffChildren(
oldVNode =
(~childVNode._index && oldChildren[childVNode._index]) || EMPTY_OBJ;
- // Update childVNode._index to its final index
+ // Update childVNode._index and ._depth to their final values
childVNode._index = i;
+ childVNode._depth = newParentVNode._depth + 1;
// Morph the old element into the new one, but don't append it to the dom yet
let result = diff(
@@ -174,13 +175,18 @@ function constructNewChildrenArray(
let childVNode;
/** @type {VNode} */
let oldVNode;
+ /** @type {number} */
+ let lo;
+ /** @type {number} */
+ let mid;
+ /** @type {number | undefined} */
+ let hi;
let oldChildrenLength = oldChildren.length,
remainingOldChildren = oldChildrenLength;
let skew = 0;
-
- newParentVNode._children = new Array(newChildrenLength);
+ let children = (newParentVNode._children = new Array(newChildrenLength));
for (i = 0; i < newChildrenLength; i++) {
// @ts-expect-error We are reusing the childVNode variable to hold both the
// pre and post normalized childVNode
@@ -191,7 +197,7 @@ function constructNewChildrenArray(
typeof childVNode == 'boolean' ||
typeof childVNode == 'function'
) {
- newParentVNode._children[i] = NULL;
+ children[i] = NULL;
continue;
}
// If this newVNode is being reused (e.g.
{reuse}{reuse}
) in the same diff,
@@ -204,7 +210,7 @@ function constructNewChildrenArray(
typeof childVNode != 'object' ||
childVNode.constructor == String
) {
- childVNode = newParentVNode._children[i] = createVNode(
+ childVNode = children[i] = createVNode(
NULL,
childVNode,
NULL,
@@ -212,19 +218,19 @@ function constructNewChildrenArray(
NULL
);
} else if (isArray(childVNode)) {
- childVNode = newParentVNode._children[i] = createVNode(
+ childVNode = children[i] = createVNode(
Fragment,
{ children: childVNode },
NULL,
NULL,
NULL
);
- } else if (childVNode.constructor === UNDEFINED && childVNode._depth > 0) {
+ } else if (childVNode.constructor === UNDEFINED && childVNode._depth) {
// VNode is already in use, clone it. This can happen in the following
// scenario:
// const reuse =
// {reuse}{reuse}
- childVNode = newParentVNode._children[i] = createVNode(
+ childVNode = children[i] = createVNode(
childVNode.type,
childVNode.props,
childVNode.key,
@@ -232,16 +238,19 @@ function constructNewChildrenArray(
childVNode._original
);
} else {
- newParentVNode._children[i] = childVNode;
+ children[i] = childVNode;
}
- const skewedIndex = i + skew;
childVNode._parent = newParentVNode;
- childVNode._depth = newParentVNode._depth + 1;
+ // Temporarily reuse the _depth property for storing the longest increasing
+ // subsequence length that ends at the current node. The correct depth value
+ // will be restored in diffChildren.
+ childVNode._depth = 1;
// Temporarily store the matchingIndex on the _index property so we can pull
// out the oldVNode in diffChildren. We'll override this to the VNode's
// final index after using this property to get the oldVNode
+ const skewedIndex = i + skew;
const matchingIndex = (childVNode._index = findMatchingIndex(
childVNode,
oldChildren,
@@ -249,47 +258,36 @@ function constructNewChildrenArray(
remainingOldChildren
));
- oldVNode = NULL;
// ~matchingIndex is only falsy for -1, i.e. when no match was found
if (~matchingIndex) {
oldVNode = oldChildren[matchingIndex];
- remainingOldChildren--;
if (oldVNode) {
oldVNode._flags |= MATCHED;
}
+ remainingOldChildren--;
+ } else {
+ // When the array of children is growing we need to decrease the skew
+ // as we are adding a new element to the array.
+ // Example:
+ // [1, 2, 3] --> [0, 1, 2, 3]
+ // oldChildren newChildren
+ //
+ // The new element is at index 0, so our skew is 0,
+ // we need to decrease the skew as we are adding a new element.
+ // The decrease will cause us to compare the element at position 1
+ // with value 1 with the element at position 0 with value 0.
+ //
+ // A linear concept is applied when the array is shrinking,
+ // if the length is unchanged we can assume that no skew
+ // changes are needed.
+ skew += Math.sign(oldChildrenLength - newChildrenLength);
+ oldVNode = NULL;
}
// Here, we define isMounting for the purposes of the skew diffing
// algorithm. Nodes that are unsuspending are considered mounting and we detect
// this by checking if oldVNode._original == null
- if (!oldVNode || !oldVNode._original) {
- if (!~matchingIndex) {
- // When the array of children is growing we need to decrease the skew
- // as we are adding a new element to the array.
- // Example:
- // [1, 2, 3] --> [0, 1, 2, 3]
- // oldChildren newChildren
- //
- // The new element is at index 0, so our skew is 0,
- // we need to decrease the skew as we are adding a new element.
- // The decrease will cause us to compare the element at position 1
- // with value 1 with the element at position 0 with value 0.
- //
- // A linear concept is applied when the array is shrinking,
- // if the length is unchanged we can assume that no skew
- // changes are needed.
- if (newChildrenLength > oldChildrenLength) {
- skew--;
- } else if (newChildrenLength < oldChildrenLength) {
- skew++;
- }
- }
-
- // If we are mounting a DOM VNode, mark it for insertion
- if (typeof childVNode.type != 'function') {
- childVNode._flags |= INSERT_VNODE;
- }
- } else if (matchingIndex != skewedIndex) {
+ if (oldVNode && oldVNode._original) {
// When we move elements around i.e. [0, 1, 2] --> [1, 0, 2]
// --> we diff 1, we find it at position 1 while our skewed index is 0 and our skew is 0
// we set the skew to 1 as we found an offset.
@@ -306,21 +304,58 @@ function constructNewChildrenArray(
// If we wanted to optimize for i.e. only swaps we'd just do the last two code-branches and have
// only the first item be a re-scouting and all the others fall in their skewed counter-part.
// We could also further optimize for swaps
- if (matchingIndex == skewedIndex - 1) {
- skew--;
- } else if (matchingIndex == skewedIndex + 1) {
- skew++;
+ if (Math.abs(skewedIndex - matchingIndex) < 2) {
+ skew -= Math.sign(skewedIndex - matchingIndex);
} else {
- if (matchingIndex > skewedIndex) {
- skew--;
- } else {
- skew++;
+ skew += Math.sign(skewedIndex - matchingIndex);
+
+ // Take note that the matched nodes may not be in correct relative order,
+ // and the longest increasing subsequence algorithm needs to run.
+ hi = 1;
+ }
+ childVNode._flags |= MATCHED;
+ } else if (typeof childVNode.type != 'function') {
+ // If we are mounting a DOM VNode, mark it for insertion
+ childVNode._flags |= INSERT_VNODE;
+ }
+ }
+
+ if (hi) {
+ // The matched nodes may not be in the correct relative order.
+ // Discover the longest increasing subsequence of old node indexes
+ // in the new node array. Mark matched nodes that do NOT belong
+ // to this subsequnce to be moved (i.e. reinserted) in DOM.
+
+ /** @type {number[]} */
+ const piles = [];
+
+ for (i = 0; i < newChildrenLength; i++) {
+ childVNode = children[i];
+ if (childVNode && childVNode._flags & MATCHED) {
+ lo = 0;
+ hi = piles.length;
+ while (lo < hi) {
+ mid = (lo + hi) >> 1;
+ if (piles[mid] < childVNode._index) {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
}
+ piles[lo++] = childVNode._index;
+ childVNode._depth = lo;
+ }
+ }
- // Move this VNode's DOM if the original index (matchingIndex) doesn't
- // match the new skew index (i + new skew)
- // In the former two branches we know that it matches after skewing
- childVNode._flags |= INSERT_VNODE;
+ hi = piles.length;
+ while (i--) {
+ childVNode = children[i];
+ if (childVNode && childVNode._flags & MATCHED) {
+ if (childVNode._depth == hi) {
+ hi--;
+ } else {
+ childVNode._flags |= INSERT_VNODE;
+ }
}
}
}
diff --git a/test/browser/fragments.test.jsx b/test/browser/fragments.test.jsx
index 0f8cd6ed1d..0b11ac6031 100644
--- a/test/browser/fragments.test.jsx
+++ b/test/browser/fragments.test.jsx
@@ -803,9 +803,8 @@ describe('Fragment', () => {
expect(scratch.innerHTML).to.equal(htmlForFalse);
expectDomLogToBe(
[
- 'barHellobeep.insertBefore(
bar,
beep)',
- '
Hellobarbeep.appendChild(
Hello)',
- '
barbeepHello.appendChild(
bar)'
+ '
fooHellobeep.insertBefore(
beep,
foo)',
+ '
beepfooHello.insertBefore(
Hello,
foo)'
],
'rendering true to false'
);
@@ -817,8 +816,9 @@ describe('Fragment', () => {
expect(scratch.innerHTML).to.equal(htmlForTrue);
expectDomLogToBe(
[
- '
beepHellofoo.appendChild(
Hello)',
- '
boopfooHello.appendChild(
boop)'
+ '
beepHellofoo.insertBefore(
foo,
beep)',
+ '
foobeepHello.insertBefore(
foo,
beep)',
+ '
foobeepHello.insertBefore(
Hello,
beep)'
],
'rendering false to true'
);
@@ -1594,9 +1594,8 @@ describe('Fragment', () => {
expectDomLogToBe(
[
'
boop.remove()',
- '
barHellobeep.insertBefore(
bar,
beep)',
- '
Hellobarbeep.appendChild(
Hello)',
- '
barbeepHello.appendChild(
bar)'
+ '
fooHellobeep.insertBefore(
beep,
foo)',
+ '
beepfooHello.insertBefore(
Hello,
foo)'
],
'rendering from true to false'
);
@@ -1611,8 +1610,9 @@ describe('Fragment', () => {
);
expectDomLogToBe(
[
- '
beepHellofoo.appendChild(
Hello)',
- '
boopfooHello.appendChild(
boop)',
+ '
beepHellofoo.insertBefore(
foo,
beep)',
+ '
foobeepHello.insertBefore(
foo,
beep)',
+ '
foobeepHello.insertBefore(
Hello,
beep)',
'
.appendChild(#text)',
'
fooHelloboop.appendChild(
boop)'
],
@@ -1680,8 +1680,8 @@ describe('Fragment', () => {
);
expectDomLogToBe(
[
- '
barHellobeepbeepbeep.insertBefore(
bar,
beep)',
- '
Hellobarbeepbeepbeep.appendChild(
Hello)',
+ '
fooHellobeepbeepbeep.appendChild(
Hello)',
+ '
barbeepbeepbeepHello.appendChild(
Hello)',
'
barbeepbeepbeepHello.appendChild(
bar)'
],
'rendering from true to false'
@@ -1697,8 +1697,8 @@ describe('Fragment', () => {
);
expectDomLogToBe(
[
- '
beepbeepbeepHellofoo.appendChild(
Hello)',
- '
beepbeepbeepfooHello.insertBefore(
foo,
beep)',
+ '
beepbeepbeepHellofoo.insertBefore(
foo,
beep)',
+ '
foobeepbeepbeepHello.insertBefore(
foo,
beep)',
'
foobeepbeepbeepHello.insertBefore(
Hello,
beep)'
],
'rendering from false to true'
diff --git a/test/browser/keys.test.jsx b/test/browser/keys.test.jsx
index 54a672da8a..2202bf0be2 100644
--- a/test/browser/keys.test.jsx
+++ b/test/browser/keys.test.jsx
@@ -351,7 +351,7 @@ describe('keys', () => {
render(
, scratch);
expect(scratch.textContent).to.equal('ba');
- expect(getLog()).to.deep.equal(['
ab.appendChild(- a)']);
+ expect(getLog()).to.deep.equal(['
ab.insertBefore(- b,
- a)']);
});
it('should swap existing keyed children in the middle of a list efficiently', () => {
@@ -367,7 +367,7 @@ describe('keys', () => {
render(
, scratch);
expect(scratch.textContent).to.equal('acbd', 'initial swap');
expect(getLog()).to.deep.equal(
- ['abcd.insertBefore(- b,
- d)'],
+ ['
abcd.insertBefore(- c,
- b)'],
'initial swap'
);
@@ -378,11 +378,176 @@ describe('keys', () => {
render(
, scratch);
expect(scratch.textContent).to.equal('abcd', 'swap back');
expect(getLog()).to.deep.equal(
- ['acbd.insertBefore(- c,
- d)'],
+ ['
acbd.insertBefore(- b,
- c)'],
'swap back'
);
});
+ it('should displace multiple keyed children to the end efficiently', () => {
+ const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('abcdefghij');
+
+ values.push(...values.splice(0, 3));
+ clearLog();
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('defghijabc');
+ expect(getLog()).to.deep.equal([
+ 'abcdefghij.appendChild(- a)',
+ '
bcdefghija.appendChild(- b)',
+ '
cdefghijab.appendChild(- c)'
+ ]);
+ });
+
+ it('should not displace when the suffix after the match is shorter than the jump', () => {
+ const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('abcdefghij');
+
+ // Swap two far apart children; only the two swapped children are out of
+ // order, so only those two may move.
+ [values[1], values[8]] = [values[8], values[1]];
+ clearLog();
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('aicdefghbj');
+ expect(getLog()).to.deep.equal([
+ 'abcdefghij.insertBefore(- i,
- b)',
+ '
aibcdefghj.insertBefore(- b,
- j)'
+ ]);
+ });
+
+ it('should move the shorter suffix when more than half the list is displaced', () => {
+ const values = ['a', 'b', 'c', 'd', 'e', 'f'];
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('abcdef');
+
+ // Displacing 4 of 6 children: moving the two-child suffix is the
+ // minimal set of moves.
+ values.push(...values.splice(0, 4));
+ clearLog();
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('efabcd');
+ expect(getLog()).to.deep.equal([
+ 'abcdef.insertBefore(- e,
- a)',
+ '
eabcdf.insertBefore(- f,
- a)'
+ ]);
+ });
+
+ it('should displace multiple keyed children to the end while the list grows', () => {
+ const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('abcdefghij');
+
+ values.push(...values.splice(0, 3));
+ values.push('k');
+ clearLog();
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('defghijabck');
+ expect(getLog()).to.deep.equal([
+ 'abcdefghij.appendChild(- a)',
+ '
bcdefghija.appendChild(- b)',
+ '
cdefghijab.appendChild(- c)',
+ '
- .appendChild(#text)',
+ '
defghijabc.appendChild(- k)'
+ ]);
+ });
+
+ it('should displace multiple keyed children to the end while another is removed', () => {
+ const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('abcdefghij');
+
+ values.push(...values.splice(0, 3));
+ values.splice(values.indexOf('j'), 1);
+ clearLog();
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('defghiabc');
+ expect(getLog()).to.deep.equal([
+ ' - j.remove()',
+ '
abcdefghi.appendChild(- a)',
+ '
bcdefghia.appendChild(- b)',
+ '
cdefghiab.appendChild(- c)'
+ ]);
+ });
+
+ it('should displace keyed children to the end repeatedly', () => {
+ const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
+ const expectedDisplaceLogs = [
+ [
+ '
abcdefghij.appendChild(- a)',
+ '
bcdefghija.appendChild(- b)',
+ '
cdefghijab.appendChild(- c)'
+ ],
+ [
+ '
defghijabc.appendChild(- d)',
+ '
efghijabcd.appendChild(- e)',
+ '
fghijabcde.appendChild(- f)'
+ ],
+ [
+ '
ghijabcdef.appendChild(- g)',
+ '
hijabcdefg.appendChild(- h)',
+ '
ijabcdefgh.appendChild(- i)'
+ ]
+ ];
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal('abcdefghij');
+
+ for (let n = 0; n < 3; n++) {
+ values.push(...values.splice(0, 3));
+ clearLog();
+
+ render(
, scratch);
+ expect(scratch.textContent).to.equal(values.join(''));
+ expect(getLog()).to.deep.equal(expectedDisplaceLogs[n], `round ${n}`);
+ }
+ });
+
+ it('should keep text siblings correct around displaced keyed children', () => {
+ const content = condition => (
+
+ {condition
+ ? [
+ - a
,
+ - b
,
+ - c
,
+ 'mid',
+ - d
,
+ - e
+ ]
+ : [
+ - c
,
+ 'mid',
+ - a
,
+ - b
,
+ - d
,
+ - e
+ ]}
+
+ );
+
+ render(content(true), scratch);
+ expect(scratch.innerHTML).to.equal(
+ '- a
- b
- c
mid- d
- e
'
+ );
+
+ clearLog();
+ render(content(false), scratch);
+ expect(scratch.innerHTML).to.equal(
+ '- c
mid- a
- b
- d
- e
'
+ );
+ });
+
it('should move keyed children to the end of the list', () => {
const values = ['a', 'b', 'c', 'd'];
@@ -463,7 +628,7 @@ describe('keys', () => {
'jihgfabcde.insertBefore(- e,
- a)',
'
jihgfeabcd.insertBefore(- d,
- a)',
'
jihgfedabc.insertBefore(- c,
- a)',
- '
jihgfedcab.appendChild(- a)'
+ '
jihgfedcab.insertBefore(- b,
- a)'
]);
});
diff --git a/test/browser/lifecycles/shouldComponentUpdate.test.jsx b/test/browser/lifecycles/shouldComponentUpdate.test.jsx
index 58cf7d1762..ac483ef4af 100644
--- a/test/browser/lifecycles/shouldComponentUpdate.test.jsx
+++ b/test/browser/lifecycles/shouldComponentUpdate.test.jsx
@@ -1068,7 +1068,7 @@ describe('Lifecycle methods', () => {
items: [7, 6, 5, 4, 3, 2, 1],
expectedLog: [
'
7634521.insertBefore(
5,
3)',
- '
7653421.insertBefore(
3,
2)'
+ '
7653421.insertBefore(
4,
3)'
]
});
});
diff --git a/test/browser/render.test.jsx b/test/browser/render.test.jsx
index 93b69e3b9b..1fb9dff4c7 100644
--- a/test/browser/render.test.jsx
+++ b/test/browser/render.test.jsx
@@ -1754,10 +1754,10 @@ describe('render()', () => {
expect(getLog()).to.deep.equal([
'
.appendChild(#text)',
'
1352640.insertBefore(
11,
1)',
- '
111352640.insertBefore(
1,
5)',
- '
113152640.insertBefore(
6,
0)',
- '
113152460.insertBefore(
2,
0)',
- '
113154620.insertBefore(
5,
0)',
+ '
111352640.insertBefore(
3,
1)',
+ '
113152640.insertBefore(
4,
5)',
+ '
113145260.insertBefore(
6,
5)',
+ '
113146520.insertBefore(
2,
5)',
'
.appendChild(#text)',
'
113146250.appendChild(
9)',
'
.appendChild(#text)',