Skip to content

Commit da26ab2

Browse files
etrepumclaude
andauthored
[lexical] Bug Fix: Skip $reconcileChildren fast path during full reconcile (#8564)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent a1e1a38 commit da26ab2

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

packages/lexical/src/LexicalReconciler.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,19 @@ function $reconcileChildren(
971971
const sizeDelta = nextChildrenSize - prevChildrenSize;
972972
if (
973973
!__benchOnly.skipChildrenFastPath &&
974+
// A FULL_RECONCILE (e.g. `setEditorState`, which backs history
975+
// undo/redo) swaps the whole node map wholesale without routing
976+
// structural changes through `getWritable()`, so `_cloneNotNeeded`
977+
// is empty even when prev and next children differ by key. That
978+
// breaks the `sizeDelta === 0` walk below, which starts at
979+
// `prevElement.__first` but advances via the next map's `__next`
980+
// pointers — assuming both lists hold the same keys in the same
981+
// order. With a same-size key swap (undo replacing a CodeNode with
982+
// the paragraphs it came from) the walk reaches a next-only key and
983+
// `$reconcileNode` throws on the missing prev node (#8563). Dirty
984+
// tracking is meaningless in this mode anyway, so fall through to the
985+
// general key-diffing path.
986+
!treatAllNodesAsDirty &&
974987
Math.abs(sizeDelta) <= 1 &&
975988
prevChildrenSize >= MIN_FAST_PATH_CHILDREN &&
976989
prevElement.__first === nextElement.__first &&
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
*/
8+
9+
import {buildEditorFromExtensions} from '@lexical/extension';
10+
import {RichTextExtension} from '@lexical/rich-text';
11+
import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
12+
import {describe, expect, test} from 'vitest';
13+
14+
// Regression test for https://github.com/facebook/lexical/issues/8563
15+
//
16+
// Undo applies the previous editor state via `setEditorState`, which runs a
17+
// FULL_RECONCILE without routing any structural change through
18+
// `getWritable()`. That leaves `_cloneNotNeeded` empty, which the
19+
// `sizeDelta === 0` children fast path used as its signal that the parent's
20+
// children were structurally unchanged (same keys in the same order). When a
21+
// child is replaced by a different-key child of equal count (e.g. undo turning
22+
// a code block back into the paragraphs it was made from), the fast path
23+
// walked from `prevElement.__first` while following the *next* map's `__next`
24+
// pointers and reached a key that only exists in the next state, throwing
25+
// `reconcileNode: prevNode or nextNode does not exist in nodeMap`.
26+
describe('Issue #8563: full reconcile with same-size child key swap', () => {
27+
test('undo (setEditorState) does not crash when a child is replaced by a different-key child of equal count', () => {
28+
const errors: Error[] = [];
29+
using editor = buildEditorFromExtensions({
30+
dependencies: [RichTextExtension],
31+
name: 'issue-8563-repro',
32+
onError: e => {
33+
errors.push(e);
34+
},
35+
});
36+
// A root element makes DOM reconciliation run; the crash is in node-map
37+
// lookups so the element does not need to be attached to the document.
38+
// `using` disposal calls setRootElement(null).
39+
editor.setRootElement(document.createElement('div'));
40+
41+
// State A: enough children to engage the fast path (>= 4).
42+
editor.update(
43+
() => {
44+
const root = $getRoot();
45+
root.clear();
46+
for (let i = 0; i < 5; i++) {
47+
root.append(
48+
$createParagraphNode().append($createTextNode(`line ${i}`)),
49+
);
50+
}
51+
},
52+
{discrete: true},
53+
);
54+
const stateA = editor.getEditorState();
55+
56+
// State B: replace only the last child, keeping the count at 5 and the
57+
// first child key unchanged. This is the shape an undo of a block
58+
// transform produces (a same-size child swap at the end of root).
59+
editor.update(
60+
() => {
61+
$getRoot()
62+
.getLastChildOrThrow()
63+
.replace($createParagraphNode().append($createTextNode('replaced')));
64+
},
65+
{discrete: true},
66+
);
67+
68+
// Undo: restore state A. FULL_RECONCILE from B with a same-size,
69+
// different-last-key children list — used to crash reconciliation.
70+
editor.setEditorState(stateA);
71+
72+
expect(errors).toEqual([]);
73+
expect(editor.read(() => $getRoot().getTextContent())).toBe(
74+
'line 0\n\nline 1\n\nline 2\n\nline 3\n\nline 4',
75+
);
76+
expect(editor.getRootElement()!.textContent).toBe(
77+
'line 0line 1line 2line 3line 4',
78+
);
79+
});
80+
});

0 commit comments

Comments
 (0)