From f9594d87db600c36b8878afdbe8df6365085ff6d Mon Sep 17 00:00:00 2001 From: Joachim Viide Date: Tue, 28 Jul 2026 11:06:56 +0000 Subject: [PATCH 1/3] feat: discover longest increasing subsequence to minimize DOM node moves --- src/diff/children.js | 141 +++++++++++++++++++++++++++---------------- 1 file changed, 88 insertions(+), 53 deletions(-) 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; + } } } } From c29a8ab4dc295ff59ceacf1473c87db405139af3 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Wed, 29 Jul 2026 23:33:45 +0300 Subject: [PATCH 2/3] test: add child reordering edge cases --- test/browser/fragments.test.jsx | 28 +++++++++---------- test/browser/keys.test.jsx | 26 ++++++++++++++--- .../lifecycles/shouldComponentUpdate.test.jsx | 2 +- test/browser/render.test.jsx | 8 +++--- 4 files changed, 41 insertions(+), 23 deletions(-) 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..95cdd7e59b 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(
  1. a)']); + expect(getLog()).to.deep.equal(['
      ab.insertBefore(
    1. b,
    2. 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(
      1. b,
      2. d)'], + ['
          abcd.insertBefore(
        1. c,
        2. b)'], 'initial swap' ); @@ -378,11 +378,29 @@ describe('keys', () => { render(, scratch); expect(scratch.textContent).to.equal('abcd', 'swap back'); expect(getLog()).to.deep.equal( - ['
            acbd.insertBefore(
          1. c,
          2. d)'], + ['
              acbd.insertBefore(
            1. b,
            2. 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(
              1. a)', + '
                  bcdefghija.appendChild(
                1. b)', + '
                    cdefghijab.appendChild(
                  1. c)' + ]); + }); + it('should move keyed children to the end of the list', () => { const values = ['a', 'b', 'c', 'd']; @@ -463,7 +481,7 @@ describe('keys', () => { '
                      jihgfabcde.insertBefore(
                    1. e,
                    2. a)', '
                        jihgfeabcd.insertBefore(
                      1. d,
                      2. a)', '
                          jihgfedabc.insertBefore(
                        1. c,
                        2. a)', - '
                            jihgfedcab.appendChild(
                          1. a)' + '
                              jihgfedcab.insertBefore(
                            1. b,
                            2. 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)', From 47781a2944a2d0d696714fe384e55c6fd361299a Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Wed, 22 Jul 2026 18:00:01 +0200 Subject: [PATCH 3/3] Add displacement edge-case tests Mirrors the edge-case coverage added to the v10.x displacement heuristic (#5172), where the minimal-move pass produces identical operation logs for every case: - a far swap moves only the two swapped children - displacing more than half the list moves the shorter suffix - displacement combined with an appended or removed child - three consecutive displacements to catch state accumulation issues - correctness of raw text siblings around displaced keyed children --- test/browser/keys.test.jsx | 147 +++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/test/browser/keys.test.jsx b/test/browser/keys.test.jsx index 95cdd7e59b..2202bf0be2 100644 --- a/test/browser/keys.test.jsx +++ b/test/browser/keys.test.jsx @@ -401,6 +401,153 @@ describe('keys', () => { ]); }); + 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(
                              1. i,
                              2. b)', + '
                                  aibcdefghj.insertBefore(
                                1. b,
                                2. 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(
                                  1. e,
                                  2. a)', + '
                                      eabcdf.insertBefore(
                                    1. f,
                                    2. 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(
                                      1. a)', + '
                                          bcdefghija.appendChild(
                                        1. b)', + '
                                            cdefghijab.appendChild(
                                          1. c)', + '
                                          2. .appendChild(#text)', + '
                                              defghijabc.appendChild(
                                            1. 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([ + '
                                            2. j.remove()', + '
                                                abcdefghi.appendChild(
                                              1. a)', + '
                                                  bcdefghia.appendChild(
                                                1. b)', + '
                                                    cdefghiab.appendChild(
                                                  1. 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(
                                                    1. a)', + '
                                                        bcdefghija.appendChild(
                                                      1. b)', + '
                                                          cdefghijab.appendChild(
                                                        1. c)' + ], + [ + '
                                                            defghijabc.appendChild(
                                                          1. d)', + '
                                                              efghijabcd.appendChild(
                                                            1. e)', + '
                                                                fghijabcde.appendChild(
                                                              1. f)' + ], + [ + '
                                                                  ghijabcdef.appendChild(
                                                                1. g)', + '
                                                                    hijabcdefg.appendChild(
                                                                  1. h)', + '
                                                                      ijabcdefgh.appendChild(
                                                                    1. 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 + ? [ +
                                                                      1. a
                                                                      2. , +
                                                                      3. b
                                                                      4. , +
                                                                      5. c
                                                                      6. , + 'mid', +
                                                                      7. d
                                                                      8. , +
                                                                      9. e
                                                                      10. + ] + : [ +
                                                                      11. c
                                                                      12. , + 'mid', +
                                                                      13. a
                                                                      14. , +
                                                                      15. b
                                                                      16. , +
                                                                      17. d
                                                                      18. , +
                                                                      19. e
                                                                      20. + ]} +
                                                                      + ); + + render(content(true), scratch); + expect(scratch.innerHTML).to.equal( + '
                                                                      1. a
                                                                      2. b
                                                                      3. c
                                                                      4. mid
                                                                      5. d
                                                                      6. e
                                                                      ' + ); + + clearLog(); + render(content(false), scratch); + expect(scratch.innerHTML).to.equal( + '
                                                                      1. c
                                                                      2. mid
                                                                      3. a
                                                                      4. b
                                                                      5. d
                                                                      6. e
                                                                      ' + ); + }); + it('should move keyed children to the end of the list', () => { const values = ['a', 'b', 'c', 'd'];