]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts
28a203100c42cbd9134defc73e0260ca2ee7aacc
[bookstack] / resources / js / wysiwyg / lexical / core / __tests__ / unit / LexicalEditor.test.ts
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 {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
10 import {
11   $createTableCellNode,
12   $createTableNode,
13   $createTableRowNode,
14   TableCellNode,
15   TableRowNode,
16 } from '@lexical/table';
17 import {
18   $createLineBreakNode,
19   $createNodeSelection,
20   $createParagraphNode,
21   $createRangeSelection,
22   $createTextNode,
23   $getEditor,
24   $getNearestNodeFromDOMNode,
25   $getNodeByKey,
26   $getRoot,
27   $isParagraphNode,
28   $isTextNode,
29   $parseSerializedNode,
30   $setCompositionKey,
31   $setSelection,
32   COMMAND_PRIORITY_EDITOR,
33   COMMAND_PRIORITY_LOW,
34   createCommand,
35   createEditor,
36   EditorState,
37   ElementNode,
38   type Klass,
39   type LexicalEditor,
40   type LexicalNode,
41   type LexicalNodeReplacement,
42   ParagraphNode,
43   RootNode,
44   TextNode,
45 } from 'lexical';
46
47 import invariant from 'lexical/shared/invariant';
48
49 import {
50   $createTestElementNode,
51   $createTestInlineElementNode,
52   createTestEditor,
53   createTestHeadlessEditor,
54   TestTextNode,
55 } from '../utils';
56
57 describe('LexicalEditor tests', () => {
58   let container: HTMLElement;
59   function setContainerChild(el: HTMLElement) {
60     container.innerHTML = '';
61     container.append(el);
62   }
63
64   beforeEach(() => {
65     container = document.createElement('div');
66     document.body.appendChild(container);
67   });
68
69   afterEach(() => {
70     document.body.removeChild(container);
71     // @ts-ignore
72     container = null;
73
74     jest.restoreAllMocks();
75   });
76
77   function useLexicalEditor(
78     rootElement: HTMLDivElement,
79     onError?: (error: Error) => void,
80     nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>,
81   ) {
82     const editor = createTestEditor({
83       nodes: nodes ?? [],
84       onError: onError || jest.fn(),
85       theme: {
86         text: {
87           bold: 'editor-text-bold',
88           italic: 'editor-text-italic',
89           underline: 'editor-text-underline',
90         },
91       },
92     });
93     editor.setRootElement(rootElement);
94     return editor;
95   }
96
97   let editor: LexicalEditor;
98
99   function init(onError?: (error: Error) => void) {
100     const edContainer = document.createElement('div');
101     edContainer.setAttribute('contenteditable', 'true');
102
103     setContainerChild(edContainer);
104     editor = useLexicalEditor(edContainer, onError);
105   }
106
107   async function update(fn: () => void) {
108     editor.update(fn);
109
110     return Promise.resolve().then();
111   }
112
113   describe('read()', () => {
114     it('Can read the editor state', async () => {
115       init(function onError(err) {
116         throw err;
117       });
118       expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
119       expect(editor.read(() => $getEditor())).toBe(editor);
120       const onUpdate = jest.fn();
121       editor.update(
122         () => {
123           const root = $getRoot();
124           const paragraph = $createParagraphNode();
125           const text = $createTextNode('This works!');
126           root.append(paragraph);
127           paragraph.append(text);
128         },
129         {onUpdate},
130       );
131       expect(onUpdate).toHaveBeenCalledTimes(0);
132       // This read will flush pending updates
133       expect(editor.read(() => $getRoot().getTextContent())).toEqual(
134         'This works!',
135       );
136       expect(onUpdate).toHaveBeenCalledTimes(1);
137       // Check to make sure there is not an unexpected reconciliation
138       await Promise.resolve().then();
139       expect(onUpdate).toHaveBeenCalledTimes(1);
140       editor.read(() => {
141         const rootElement = editor.getRootElement();
142         expect(rootElement).toBeDefined();
143         // The root never works for this call
144         expect($getNearestNodeFromDOMNode(rootElement!)).toBe(null);
145         const paragraphDom = rootElement!.querySelector('p');
146         expect(paragraphDom).toBeDefined();
147         expect(
148           $isParagraphNode($getNearestNodeFromDOMNode(paragraphDom!)),
149         ).toBe(true);
150         expect(
151           $getNearestNodeFromDOMNode(paragraphDom!)!.getTextContent(),
152         ).toBe('This works!');
153         const textDom = paragraphDom!.querySelector('span');
154         expect(textDom).toBeDefined();
155         expect($isTextNode($getNearestNodeFromDOMNode(textDom!))).toBe(true);
156         expect($getNearestNodeFromDOMNode(textDom!)!.getTextContent()).toBe(
157           'This works!',
158         );
159         expect(
160           $getNearestNodeFromDOMNode(textDom!.firstChild!)!.getTextContent(),
161         ).toBe('This works!');
162       });
163       expect(onUpdate).toHaveBeenCalledTimes(1);
164     });
165     it('runs transforms the editor state', async () => {
166       init(function onError(err) {
167         throw err;
168       });
169       expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
170       expect(editor.read(() => $getEditor())).toBe(editor);
171       editor.registerNodeTransform(TextNode, (node) => {
172         if (node.getTextContent() === 'This works!') {
173           node.replace($createTextNode('Transforms work!'));
174         }
175       });
176       const onUpdate = jest.fn();
177       editor.update(
178         () => {
179           const root = $getRoot();
180           const paragraph = $createParagraphNode();
181           const text = $createTextNode('This works!');
182           root.append(paragraph);
183           paragraph.append(text);
184         },
185         {onUpdate},
186       );
187       expect(onUpdate).toHaveBeenCalledTimes(0);
188       // This read will flush pending updates
189       expect(editor.read(() => $getRoot().getTextContent())).toEqual(
190         'Transforms work!',
191       );
192       expect(editor.getRootElement()!.textContent).toEqual('Transforms work!');
193       expect(onUpdate).toHaveBeenCalledTimes(1);
194       // Check to make sure there is not an unexpected reconciliation
195       await Promise.resolve().then();
196       expect(onUpdate).toHaveBeenCalledTimes(1);
197       expect(editor.read(() => $getRoot().getTextContent())).toEqual(
198         'Transforms work!',
199       );
200     });
201     it('can be nested in an update or read', async () => {
202       init(function onError(err) {
203         throw err;
204       });
205       editor.update(() => {
206         const root = $getRoot();
207         const paragraph = $createParagraphNode();
208         const text = $createTextNode('This works!');
209         root.append(paragraph);
210         paragraph.append(text);
211         editor.read(() => {
212           expect($getRoot().getTextContent()).toBe('This works!');
213         });
214         editor.read(() => {
215           // Nesting update in read works, although it is discouraged in the documentation.
216           editor.update(() => {
217             expect($getRoot().getTextContent()).toBe('This works!');
218           });
219         });
220         // Updating after a nested read will fail as it has already been committed
221         expect(() => {
222           root.append(
223             $createParagraphNode().append(
224               $createTextNode('update-read-update'),
225             ),
226           );
227         }).toThrow();
228       });
229       editor.read(() => {
230         editor.read(() => {
231           expect($getRoot().getTextContent()).toBe('This works!');
232         });
233       });
234     });
235   });
236
237   it('Should create an editor with an initial editor state', async () => {
238     const rootElement = document.createElement('div');
239
240     container.appendChild(rootElement);
241
242     const initialEditor = createTestEditor({
243       onError: jest.fn(),
244     });
245
246     initialEditor.update(() => {
247       const root = $getRoot();
248       const paragraph = $createParagraphNode();
249       const text = $createTextNode('This works!');
250       root.append(paragraph);
251       paragraph.append(text);
252     });
253
254     initialEditor.setRootElement(rootElement);
255
256     // Wait for update to complete
257     await Promise.resolve().then();
258
259     expect(container.innerHTML).toBe(
260       '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">This works!</span></p></div>',
261     );
262
263     const initialEditorState = initialEditor.getEditorState();
264     initialEditor.setRootElement(null);
265
266     expect(container.innerHTML).toBe(
267       '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"></div>',
268     );
269
270     editor = createTestEditor({
271       editorState: initialEditorState,
272       onError: jest.fn(),
273     });
274     editor.setRootElement(rootElement);
275
276     expect(editor.getEditorState()).toEqual(initialEditorState);
277     expect(container.innerHTML).toBe(
278       '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">This works!</span></p></div>',
279     );
280   });
281
282   it('Should handle nested updates in the correct sequence', async () => {
283     init();
284     const onUpdate = jest.fn();
285
286     let log: Array<string> = [];
287
288     editor.registerUpdateListener(onUpdate);
289     editor.update(() => {
290       const root = $getRoot();
291       const paragraph = $createParagraphNode();
292       const text = $createTextNode('This works!');
293       root.append(paragraph);
294       paragraph.append(text);
295     });
296
297     editor.update(
298       () => {
299         log.push('A1');
300         // To enforce the update
301         $getRoot().markDirty();
302         editor.update(
303           () => {
304             log.push('B1');
305             editor.update(
306               () => {
307                 log.push('C1');
308               },
309               {
310                 onUpdate: () => {
311                   log.push('F1');
312                 },
313               },
314             );
315           },
316           {
317             onUpdate: () => {
318               log.push('E1');
319             },
320           },
321         );
322       },
323       {
324         onUpdate: () => {
325           log.push('D1');
326         },
327       },
328     );
329
330     // Wait for update to complete
331     await Promise.resolve().then();
332
333     expect(onUpdate).toHaveBeenCalledTimes(1);
334     expect(log).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1']);
335
336     log = [];
337     editor.update(
338       () => {
339         log.push('A2');
340         // To enforce the update
341         $getRoot().markDirty();
342       },
343       {
344         onUpdate: () => {
345           log.push('B2');
346           editor.update(
347             () => {
348               // force flush sync
349               $setCompositionKey('root');
350               log.push('D2');
351             },
352             {
353               onUpdate: () => {
354                 log.push('F2');
355               },
356             },
357           );
358           log.push('C2');
359           editor.update(
360             () => {
361               log.push('E2');
362             },
363             {
364               onUpdate: () => {
365                 log.push('G2');
366               },
367             },
368           );
369         },
370       },
371     );
372
373     // Wait for update to complete
374     await Promise.resolve().then();
375
376     expect(log).toEqual(['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2']);
377
378     log = [];
379     editor.registerNodeTransform(TextNode, () => {
380       log.push('TextTransform A3');
381       editor.update(
382         () => {
383           log.push('TextTransform B3');
384         },
385         {
386           onUpdate: () => {
387             log.push('TextTransform C3');
388           },
389         },
390       );
391     });
392
393     // Wait for update to complete
394     await Promise.resolve().then();
395
396     expect(log).toEqual([
397       'TextTransform A3',
398       'TextTransform B3',
399       'TextTransform C3',
400     ]);
401
402     log = [];
403     editor.update(
404       () => {
405         log.push('A3');
406         $getRoot().getLastDescendant()!.markDirty();
407       },
408       {
409         onUpdate: () => {
410           log.push('B3');
411         },
412       },
413     );
414
415     // Wait for update to complete
416     await Promise.resolve().then();
417
418     expect(log).toEqual([
419       'A3',
420       'TextTransform A3',
421       'TextTransform B3',
422       'B3',
423       'TextTransform C3',
424     ]);
425   });
426
427   it('nested update after selection update triggers exactly 1 update', async () => {
428     init();
429     const onUpdate = jest.fn();
430     editor.registerUpdateListener(onUpdate);
431     editor.update(() => {
432       $setSelection($createRangeSelection());
433       editor.update(() => {
434         $getRoot().append(
435           $createParagraphNode().append($createTextNode('Sync update')),
436         );
437       });
438     });
439
440     await Promise.resolve().then();
441
442     const textContent = editor
443       .getEditorState()
444       .read(() => $getRoot().getTextContent());
445     expect(textContent).toBe('Sync update');
446     expect(onUpdate).toHaveBeenCalledTimes(1);
447   });
448
449   it('update does not call onUpdate callback when no dirty nodes', () => {
450     init();
451
452     const fn = jest.fn();
453     editor.update(
454       () => {
455         //
456       },
457       {
458         onUpdate: fn,
459       },
460     );
461     expect(fn).toHaveBeenCalledTimes(0);
462   });
463
464   it('editor.focus() callback is called', async () => {
465     init();
466
467     await editor.update(() => {
468       const root = $getRoot();
469       root.append($createParagraphNode());
470     });
471
472     const fn = jest.fn();
473
474     await editor.focus(fn);
475
476     expect(fn).toHaveBeenCalledTimes(1);
477   });
478
479   it('Synchronously runs three transforms, two of them depend on the other', async () => {
480     init();
481
482     // 2. Add italics
483     const italicsListener = editor.registerNodeTransform(TextNode, (node) => {
484       if (
485         node.getTextContent() === 'foo' &&
486         node.hasFormat('bold') &&
487         !node.hasFormat('italic')
488       ) {
489         node.toggleFormat('italic');
490       }
491     });
492
493     // 1. Add bold
494     const boldListener = editor.registerNodeTransform(TextNode, (node) => {
495       if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) {
496         node.toggleFormat('bold');
497       }
498     });
499
500     // 2. Add underline
501     const underlineListener = editor.registerNodeTransform(TextNode, (node) => {
502       if (
503         node.getTextContent() === 'foo' &&
504         node.hasFormat('bold') &&
505         !node.hasFormat('underline')
506       ) {
507         node.toggleFormat('underline');
508       }
509     });
510
511     await editor.update(() => {
512       const root = $getRoot();
513       const paragraph = $createParagraphNode();
514       root.append(paragraph);
515       paragraph.append($createTextNode('foo'));
516     });
517     italicsListener();
518     boldListener();
519     underlineListener();
520
521     expect(container.innerHTML).toBe(
522       '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><strong class="editor-text-bold editor-text-italic editor-text-underline" data-lexical-text="true">foo</strong></p></div>',
523     );
524   });
525
526   it('Synchronously runs three transforms, two of them depend on the other (2)', async () => {
527     await init();
528
529     // Add transform makes everything dirty the first time (let's not leverage this here)
530     const skipFirst = [true, true, true];
531
532     // 2. (Block transform) Add text
533     const testParagraphListener = editor.registerNodeTransform(
534       ParagraphNode,
535       (paragraph) => {
536         if (skipFirst[0]) {
537           skipFirst[0] = false;
538
539           return;
540         }
541
542         if (paragraph.isEmpty()) {
543           paragraph.append($createTextNode('foo'));
544         }
545       },
546     );
547
548     // 2. (Text transform) Add bold to text
549     const boldListener = editor.registerNodeTransform(TextNode, (node) => {
550       if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) {
551         node.toggleFormat('bold');
552       }
553     });
554
555     // 3. (Block transform) Add italics to bold text
556     const italicsListener = editor.registerNodeTransform(
557       ParagraphNode,
558       (paragraph) => {
559         const child = paragraph.getLastDescendant();
560
561         if (
562           $isTextNode(child) &&
563           child.hasFormat('bold') &&
564           !child.hasFormat('italic')
565         ) {
566           child.toggleFormat('italic');
567         }
568       },
569     );
570
571     await editor.update(() => {
572       const root = $getRoot();
573       const paragraph = $createParagraphNode();
574       root.append(paragraph);
575     });
576
577     await editor.update(() => {
578       const root = $getRoot();
579       const paragraph = root.getFirstChild();
580       paragraph!.markDirty();
581     });
582
583     testParagraphListener();
584     boldListener();
585     italicsListener();
586
587     expect(container.innerHTML).toBe(
588       '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><strong class="editor-text-bold editor-text-italic" data-lexical-text="true">foo</strong></p></div>',
589     );
590   });
591
592   it('Synchronously runs three transforms, two of them depend on previously merged text content', async () => {
593     const hasRun = [false, false, false];
594     init();
595
596     // 1. [Foo] into [<empty>,Fo,o,<empty>,!,<empty>]
597     const fooListener = editor.registerNodeTransform(TextNode, (node) => {
598       if (node.getTextContent() === 'Foo' && !hasRun[0]) {
599         const [before, after] = node.splitText(2);
600
601         before.insertBefore($createTextNode(''));
602         after.insertAfter($createTextNode(''));
603         after.insertAfter($createTextNode('!'));
604         after.insertAfter($createTextNode(''));
605
606         hasRun[0] = true;
607       }
608     });
609
610     // 2. [Foo!] into [<empty>,Fo,o!,<empty>,!,<empty>]
611     const megaFooListener = editor.registerNodeTransform(
612       ParagraphNode,
613       (paragraph) => {
614         const child = paragraph.getFirstChild();
615
616         if (
617           $isTextNode(child) &&
618           child.getTextContent() === 'Foo!' &&
619           !hasRun[1]
620         ) {
621           const [before, after] = child.splitText(2);
622
623           before.insertBefore($createTextNode(''));
624           after.insertAfter($createTextNode(''));
625           after.insertAfter($createTextNode('!'));
626           after.insertAfter($createTextNode(''));
627
628           hasRun[1] = true;
629         }
630       },
631     );
632
633     // 3. [Foo!!] into formatted bold [<empty>,Fo,o!!,<empty>]
634     const boldFooListener = editor.registerNodeTransform(TextNode, (node) => {
635       if (node.getTextContent() === 'Foo!!' && !hasRun[2]) {
636         node.toggleFormat('bold');
637
638         const [before, after] = node.splitText(2);
639         before.insertBefore($createTextNode(''));
640         after.insertAfter($createTextNode(''));
641
642         hasRun[2] = true;
643       }
644     });
645
646     await editor.update(() => {
647       const root = $getRoot();
648       const paragraph = $createParagraphNode();
649
650       root.append(paragraph);
651       paragraph.append($createTextNode('Foo'));
652     });
653
654     fooListener();
655     megaFooListener();
656     boldFooListener();
657
658     expect(container.innerHTML).toBe(
659       '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><strong class="editor-text-bold" data-lexical-text="true">Foo!!</strong></p></div>',
660     );
661   });
662
663   it('text transform runs when node is removed', async () => {
664     init();
665
666     const executeTransform = jest.fn();
667     let hasBeenRemoved = false;
668     const removeListener = editor.registerNodeTransform(TextNode, (node) => {
669       if (hasBeenRemoved) {
670         executeTransform();
671       }
672     });
673
674     await editor.update(() => {
675       const root = $getRoot();
676       const paragraph = $createParagraphNode();
677       root.append(paragraph);
678       paragraph.append(
679         $createTextNode('Foo').toggleUnmergeable(),
680         $createTextNode('Bar').toggleUnmergeable(),
681       );
682     });
683
684     await editor.update(() => {
685       $getRoot().getLastDescendant()!.remove();
686       hasBeenRemoved = true;
687     });
688
689     expect(executeTransform).toHaveBeenCalledTimes(1);
690
691     removeListener();
692   });
693
694   it('transforms only run on nodes that were explicitly marked as dirty', async () => {
695     init();
696
697     let executeParagraphNodeTransform = () => {
698       return;
699     };
700
701     let executeTextNodeTransform = () => {
702       return;
703     };
704
705     const removeParagraphTransform = editor.registerNodeTransform(
706       ParagraphNode,
707       (node) => {
708         executeParagraphNodeTransform();
709       },
710     );
711     const removeTextNodeTransform = editor.registerNodeTransform(
712       TextNode,
713       (node) => {
714         executeTextNodeTransform();
715       },
716     );
717
718     await editor.update(() => {
719       const root = $getRoot();
720       const paragraph = $createParagraphNode();
721       root.append(paragraph);
722       paragraph.append($createTextNode('Foo'));
723     });
724
725     await editor.update(() => {
726       const root = $getRoot();
727       const paragraph = root.getFirstChild() as ParagraphNode;
728       const textNode = paragraph.getFirstChild() as TextNode;
729
730       textNode.getWritable();
731
732       executeParagraphNodeTransform = jest.fn();
733       executeTextNodeTransform = jest.fn();
734     });
735
736     expect(executeParagraphNodeTransform).toHaveBeenCalledTimes(0);
737     expect(executeTextNodeTransform).toHaveBeenCalledTimes(1);
738
739     removeParagraphTransform();
740     removeTextNodeTransform();
741   });
742
743   describe('transforms on siblings', () => {
744     let textNodeKeys: string[];
745     let textTransformCount: number[];
746     let removeTransform: () => void;
747
748     beforeEach(async () => {
749       init();
750
751       textNodeKeys = [];
752       textTransformCount = [];
753
754       await editor.update(() => {
755         const root = $getRoot();
756         const paragraph0 = $createParagraphNode();
757         const paragraph1 = $createParagraphNode();
758         const textNodes: Array<LexicalNode> = [];
759
760         for (let i = 0; i < 6; i++) {
761           const node = $createTextNode(String(i)).toggleUnmergeable();
762           textNodes.push(node);
763           textNodeKeys.push(node.getKey());
764           textTransformCount[i] = 0;
765         }
766
767         root.append(paragraph0, paragraph1);
768         paragraph0.append(...textNodes.slice(0, 3));
769         paragraph1.append(...textNodes.slice(3));
770       });
771
772       removeTransform = editor.registerNodeTransform(TextNode, (node) => {
773         textTransformCount[Number(node.__text)]++;
774       });
775     });
776
777     afterEach(() => {
778       removeTransform();
779     });
780
781     it('on remove', async () => {
782       await editor.update(() => {
783         const textNode1 = $getNodeByKey(textNodeKeys[1])!;
784         textNode1.remove();
785       });
786       expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1]);
787     });
788
789     it('on replace', async () => {
790       await editor.update(() => {
791         const textNode1 = $getNodeByKey(textNodeKeys[1])!;
792         const textNode4 = $getNodeByKey(textNodeKeys[4])!;
793         textNode4.replace(textNode1);
794       });
795       expect(textTransformCount).toEqual([2, 2, 2, 2, 1, 2]);
796     });
797
798     it('on insertBefore', async () => {
799       await editor.update(() => {
800         const textNode1 = $getNodeByKey(textNodeKeys[1])!;
801         const textNode4 = $getNodeByKey(textNodeKeys[4])!;
802         textNode4.insertBefore(textNode1);
803       });
804       expect(textTransformCount).toEqual([2, 2, 2, 2, 2, 1]);
805     });
806
807     it('on insertAfter', async () => {
808       await editor.update(() => {
809         const textNode1 = $getNodeByKey(textNodeKeys[1])!;
810         const textNode4 = $getNodeByKey(textNodeKeys[4])!;
811         textNode4.insertAfter(textNode1);
812       });
813       expect(textTransformCount).toEqual([2, 2, 2, 1, 2, 2]);
814     });
815
816     it('on splitText', async () => {
817       await editor.update(() => {
818         const textNode1 = $getNodeByKey(textNodeKeys[1]) as TextNode;
819         textNode1.setTextContent('67');
820         textNode1.splitText(1);
821         textTransformCount.push(0, 0);
822       });
823       expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1, 1, 1]);
824     });
825
826     it('on append', async () => {
827       await editor.update(() => {
828         const paragraph1 = $getRoot().getFirstChild() as ParagraphNode;
829         paragraph1.append($createTextNode('6').toggleUnmergeable());
830         textTransformCount.push(0);
831       });
832       expect(textTransformCount).toEqual([1, 1, 2, 1, 1, 1, 1]);
833     });
834   });
835
836   it('Detects infinite recursivity on transforms', async () => {
837     const errorListener = jest.fn();
838     init(errorListener);
839
840     const boldListener = editor.registerNodeTransform(TextNode, (node) => {
841       node.toggleFormat('bold');
842     });
843
844     expect(errorListener).toHaveBeenCalledTimes(0);
845
846     await editor.update(() => {
847       const root = $getRoot();
848       const paragraph = $createParagraphNode();
849       root.append(paragraph);
850       paragraph.append($createTextNode('foo'));
851     });
852
853     expect(errorListener).toHaveBeenCalledTimes(1);
854     boldListener();
855   });
856
857   it('Should be able to update an editor state without a root element', () => {
858     const element = document.createElement('div');
859     element.setAttribute('contenteditable', 'true');
860     setContainerChild(element);
861
862     editor = createTestEditor();
863
864     editor.update(() => {
865       const root = $getRoot();
866       const paragraph = $createParagraphNode();
867       const text = $createTextNode('This works!');
868       root.append(paragraph);
869       paragraph.append(text);
870     });
871
872     expect(container.innerHTML).toBe('<div contenteditable="true"></div>');
873
874     editor.setRootElement(element);
875
876     expect(container.innerHTML).toBe(
877       '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">This works!</span></p></div>',
878     );
879   });
880
881   it('Should be able to recover from an update error', async () => {
882     const errorListener = jest.fn();
883     init(errorListener);
884     editor.update(() => {
885       const root = $getRoot();
886
887       if (root.getFirstChild() === null) {
888         const paragraph = $createParagraphNode();
889         const text = $createTextNode('This works!');
890         root.append(paragraph);
891         paragraph.append(text);
892       }
893     });
894
895     // Wait for update to complete
896     await Promise.resolve().then();
897
898     expect(container.innerHTML).toBe(
899       '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">This works!</span></p></div>',
900     );
901     expect(errorListener).toHaveBeenCalledTimes(0);
902
903     editor.update(() => {
904       const root = $getRoot();
905       root
906         .getFirstChild<ElementNode>()!
907         .getFirstChild<ElementNode>()!
908         .getFirstChild<TextNode>()!
909         .setTextContent('Foo');
910     });
911
912     expect(errorListener).toHaveBeenCalledTimes(1);
913     expect(container.innerHTML).toBe(
914       '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">This works!</span></p></div>',
915     );
916   });
917
918   it('Should be able to handle a change in root element', async () => {
919     const rootListener = jest.fn();
920     const updateListener = jest.fn();
921
922     let editorInstance = createTestEditor();
923     editorInstance.registerRootListener(rootListener);
924     editorInstance.registerUpdateListener(updateListener);
925
926     let edContainer: HTMLElement = document.createElement('div');
927     edContainer.setAttribute('contenteditable', 'true');
928     setContainerChild(edContainer);
929     editorInstance.setRootElement(edContainer);
930
931     function runUpdate(changeElement: boolean) {
932       editorInstance.update(() => {
933         const root = $getRoot();
934         const firstChild = root.getFirstChild() as ParagraphNode | null;
935         const text = changeElement ? 'Change successful' : 'Not changed';
936
937         if (firstChild === null) {
938           const paragraph = $createParagraphNode();
939           const textNode = $createTextNode(text);
940           paragraph.append(textNode);
941           root.append(paragraph);
942         } else {
943           const textNode = firstChild.getFirstChild() as TextNode;
944           textNode.setTextContent(text);
945         }
946       });
947     }
948
949     setContainerChild(edContainer);
950     editorInstance.setRootElement(edContainer);
951     runUpdate(false);
952     editorInstance.commitUpdates();
953
954     expect(container.innerHTML).toBe(
955       '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">Not changed</span></p></div>',
956     );
957
958     edContainer = document.createElement('span');
959     edContainer.setAttribute('contenteditable', 'true');
960     runUpdate(true);
961     editorInstance.setRootElement(edContainer);
962     setContainerChild(edContainer);
963     editorInstance.commitUpdates();
964
965     expect(rootListener).toHaveBeenCalledTimes(3);
966     expect(updateListener).toHaveBeenCalledTimes(3);
967     expect(container.innerHTML).toBe(
968       '<span contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><span data-lexical-text="true">Change successful</span></p></span>',
969     );
970   });
971
972   for (const editable of [true, false]) {
973     it(`Retains pendingEditor while rootNode is not set (${
974       editable ? 'editable' : 'non-editable'
975     })`, async () => {
976       const JSON_EDITOR_STATE =
977         '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"123","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
978       init();
979       const contentEditable = editor.getRootElement();
980       editor.setEditable(editable);
981       editor.setRootElement(null);
982       const editorState = editor.parseEditorState(JSON_EDITOR_STATE);
983       editor.setEditorState(editorState);
984       editor.update(() => {
985         //
986       });
987       editor.setRootElement(contentEditable);
988       expect(JSON.stringify(editor.getEditorState().toJSON())).toBe(
989         JSON_EDITOR_STATE,
990       );
991     });
992   }
993
994   describe('parseEditorState()', () => {
995     let originalText: TextNode;
996     let parsedParagraph: ParagraphNode;
997     let parsedRoot: RootNode;
998     let parsedText: TextNode;
999     let paragraphKey: string;
1000     let textKey: string;
1001     let parsedEditorState: EditorState;
1002
1003     it('exportJSON API - parses parsed JSON', async () => {
1004       await update(() => {
1005         const paragraph = $createParagraphNode();
1006         originalText = $createTextNode('Hello world');
1007         originalText.select(6, 11);
1008         paragraph.append(originalText);
1009         $getRoot().append(paragraph);
1010       });
1011       const stringifiedEditorState = JSON.stringify(editor.getEditorState());
1012       const parsedEditorStateFromObject = editor.parseEditorState(
1013         JSON.parse(stringifiedEditorState),
1014       );
1015       parsedEditorStateFromObject.read(() => {
1016         const root = $getRoot();
1017         expect(root.getTextContent()).toMatch(/Hello world/);
1018       });
1019     });
1020
1021     describe('range selection', () => {
1022       beforeEach(async () => {
1023         await init();
1024
1025         await update(() => {
1026           const paragraph = $createParagraphNode();
1027           originalText = $createTextNode('Hello world');
1028           originalText.select(6, 11);
1029           paragraph.append(originalText);
1030           $getRoot().append(paragraph);
1031         });
1032         const stringifiedEditorState = JSON.stringify(
1033           editor.getEditorState().toJSON(),
1034         );
1035         parsedEditorState = editor.parseEditorState(stringifiedEditorState);
1036         parsedEditorState.read(() => {
1037           parsedRoot = $getRoot();
1038           parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode;
1039           paragraphKey = parsedParagraph.getKey();
1040           parsedText = parsedParagraph.getFirstChild() as TextNode;
1041           textKey = parsedText.getKey();
1042         });
1043       });
1044
1045       it('Parses the nodes of a stringified editor state', async () => {
1046         expect(parsedRoot).toEqual({
1047           __cachedText: null,
1048           __dir: null,
1049           __first: paragraphKey,
1050           __format: 0,
1051           __indent: 0,
1052           __key: 'root',
1053           __last: paragraphKey,
1054           __next: null,
1055           __parent: null,
1056           __prev: null,
1057           __size: 1,
1058           __style: '',
1059           __type: 'root',
1060         });
1061         expect(parsedParagraph).toEqual({
1062           __dir: null,
1063           __first: textKey,
1064           __format: 0,
1065           __indent: 0,
1066           __key: paragraphKey,
1067           __last: textKey,
1068           __next: null,
1069           __parent: 'root',
1070           __prev: null,
1071           __size: 1,
1072           __style: '',
1073           __textFormat: 0,
1074           __textStyle: '',
1075           __type: 'paragraph',
1076         });
1077         expect(parsedText).toEqual({
1078           __detail: 0,
1079           __format: 0,
1080           __key: textKey,
1081           __mode: 0,
1082           __next: null,
1083           __parent: paragraphKey,
1084           __prev: null,
1085           __style: '',
1086           __text: 'Hello world',
1087           __type: 'text',
1088         });
1089       });
1090
1091       it('Parses the text content of the editor state', async () => {
1092         expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1093           null,
1094         );
1095         expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1096           'Hello world',
1097         );
1098       });
1099     });
1100
1101     describe('node selection', () => {
1102       beforeEach(async () => {
1103         init();
1104
1105         await update(() => {
1106           const paragraph = $createParagraphNode();
1107           originalText = $createTextNode('Hello world');
1108           const selection = $createNodeSelection();
1109           selection.add(originalText.getKey());
1110           $setSelection(selection);
1111           paragraph.append(originalText);
1112           $getRoot().append(paragraph);
1113         });
1114         const stringifiedEditorState = JSON.stringify(
1115           editor.getEditorState().toJSON(),
1116         );
1117         parsedEditorState = editor.parseEditorState(stringifiedEditorState);
1118         parsedEditorState.read(() => {
1119           parsedRoot = $getRoot();
1120           parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode;
1121           paragraphKey = parsedParagraph.getKey();
1122           parsedText = parsedParagraph.getFirstChild() as TextNode;
1123           textKey = parsedText.getKey();
1124         });
1125       });
1126
1127       it('Parses the nodes of a stringified editor state', async () => {
1128         expect(parsedRoot).toEqual({
1129           __cachedText: null,
1130           __dir: null,
1131           __first: paragraphKey,
1132           __format: 0,
1133           __indent: 0,
1134           __key: 'root',
1135           __last: paragraphKey,
1136           __next: null,
1137           __parent: null,
1138           __prev: null,
1139           __size: 1,
1140           __style: '',
1141           __type: 'root',
1142         });
1143         expect(parsedParagraph).toEqual({
1144           __dir: null,
1145           __first: textKey,
1146           __format: 0,
1147           __indent: 0,
1148           __key: paragraphKey,
1149           __last: textKey,
1150           __next: null,
1151           __parent: 'root',
1152           __prev: null,
1153           __size: 1,
1154           __style: '',
1155           __textFormat: 0,
1156           __textStyle: '',
1157           __type: 'paragraph',
1158         });
1159         expect(parsedText).toEqual({
1160           __detail: 0,
1161           __format: 0,
1162           __key: textKey,
1163           __mode: 0,
1164           __next: null,
1165           __parent: paragraphKey,
1166           __prev: null,
1167           __style: '',
1168           __text: 'Hello world',
1169           __type: 'text',
1170         });
1171       });
1172
1173       it('Parses the text content of the editor state', async () => {
1174         expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1175           null,
1176         );
1177         expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1178           'Hello world',
1179         );
1180       });
1181     });
1182   });
1183
1184   describe('$parseSerializedNode()', () => {
1185     it('parses serialized nodes', async () => {
1186       const expectedTextContent = 'Hello world\n\nHello world';
1187       let actualTextContent: string;
1188       let root: RootNode;
1189       await update(() => {
1190         root = $getRoot();
1191         root.clear();
1192         const paragraph = $createParagraphNode();
1193         paragraph.append($createTextNode('Hello world'));
1194         root.append(paragraph);
1195       });
1196       const stringifiedEditorState = JSON.stringify(editor.getEditorState());
1197       const parsedEditorStateJson = JSON.parse(stringifiedEditorState);
1198       const rootJson = parsedEditorStateJson.root;
1199       await update(() => {
1200         const children = rootJson.children.map($parseSerializedNode);
1201         root = $getRoot();
1202         root.append(...children);
1203         actualTextContent = root.getTextContent();
1204       });
1205       expect(actualTextContent!).toEqual(expectedTextContent);
1206     });
1207   });
1208
1209   describe('Node children', () => {
1210     beforeEach(async () => {
1211       init();
1212
1213       await reset();
1214     });
1215
1216     async function reset() {
1217       init();
1218
1219       await update(() => {
1220         const root = $getRoot();
1221         const paragraph = $createParagraphNode();
1222         root.append(paragraph);
1223       });
1224     }
1225
1226     it('moves node to different tree branches', async () => {
1227       function $createElementNodeWithText(text: string) {
1228         const elementNode = $createTestElementNode();
1229         const textNode = $createTextNode(text);
1230         elementNode.append(textNode);
1231
1232         return [elementNode, textNode];
1233       }
1234
1235       let paragraphNodeKey: string;
1236       let elementNode1Key: string;
1237       let textNode1Key: string;
1238       let elementNode2Key: string;
1239       let textNode2Key: string;
1240
1241       await update(() => {
1242         const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1243         paragraphNodeKey = paragraph.getKey();
1244
1245         const [elementNode1, textNode1] = $createElementNodeWithText('A');
1246         elementNode1Key = elementNode1.getKey();
1247         textNode1Key = textNode1.getKey();
1248
1249         const [elementNode2, textNode2] = $createElementNodeWithText('B');
1250         elementNode2Key = elementNode2.getKey();
1251         textNode2Key = textNode2.getKey();
1252
1253         paragraph.append(elementNode1, elementNode2);
1254       });
1255
1256       await update(() => {
1257         const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1258         const elementNode2 = $getNodeByKey(elementNode2Key) as TextNode;
1259         elementNode1.append(elementNode2);
1260       });
1261       const keys = [
1262         paragraphNodeKey!,
1263         elementNode1Key!,
1264         textNode1Key!,
1265         elementNode2Key!,
1266         textNode2Key!,
1267       ];
1268
1269       for (let i = 0; i < keys.length; i++) {
1270         expect(editor._editorState._nodeMap.has(keys[i])).toBe(true);
1271         expect(editor._keyToDOMMap.has(keys[i])).toBe(true);
1272       }
1273
1274       expect(editor._editorState._nodeMap.size).toBe(keys.length + 1); // + root
1275       expect(editor._keyToDOMMap.size).toBe(keys.length + 1); // + root
1276       expect(container.innerHTML).toBe(
1277         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><div><span data-lexical-text="true">A</span><div><span data-lexical-text="true">B</span></div></div></p></div>',
1278       );
1279     });
1280
1281     it('moves node to different tree branches (inverse)', async () => {
1282       function $createElementNodeWithText(text: string) {
1283         const elementNode = $createTestElementNode();
1284         const textNode = $createTextNode(text);
1285         elementNode.append(textNode);
1286
1287         return elementNode;
1288       }
1289
1290       let elementNode1Key: string;
1291       let elementNode2Key: string;
1292
1293       await update(() => {
1294         const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1295
1296         const elementNode1 = $createElementNodeWithText('A');
1297         elementNode1Key = elementNode1.getKey();
1298
1299         const elementNode2 = $createElementNodeWithText('B');
1300         elementNode2Key = elementNode2.getKey();
1301
1302         paragraph.append(elementNode1, elementNode2);
1303       });
1304
1305       await update(() => {
1306         const elementNode1 = $getNodeByKey(elementNode1Key) as TextNode;
1307         const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1308         elementNode2.append(elementNode1);
1309       });
1310
1311       expect(container.innerHTML).toBe(
1312         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><div><span data-lexical-text="true">B</span><div><span data-lexical-text="true">A</span></div></div></p></div>',
1313       );
1314     });
1315
1316     it('moves node to different tree branches (node appended twice in two different branches)', async () => {
1317       function $createElementNodeWithText(text: string) {
1318         const elementNode = $createTestElementNode();
1319         const textNode = $createTextNode(text);
1320         elementNode.append(textNode);
1321
1322         return elementNode;
1323       }
1324
1325       let elementNode1Key: string;
1326       let elementNode2Key: string;
1327       let elementNode3Key: string;
1328
1329       await update(() => {
1330         const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1331
1332         const elementNode1 = $createElementNodeWithText('A');
1333         elementNode1Key = elementNode1.getKey();
1334
1335         const elementNode2 = $createElementNodeWithText('B');
1336         elementNode2Key = elementNode2.getKey();
1337
1338         const elementNode3 = $createElementNodeWithText('C');
1339         elementNode3Key = elementNode3.getKey();
1340
1341         paragraph.append(elementNode1, elementNode2, elementNode3);
1342       });
1343
1344       await update(() => {
1345         const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1346         const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1347         const elementNode3 = $getNodeByKey(elementNode3Key) as TextNode;
1348         elementNode2.append(elementNode3);
1349         elementNode1.append(elementNode3);
1350       });
1351
1352       expect(container.innerHTML).toBe(
1353         '<div contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"><p><div><span data-lexical-text="true">A</span><div><span data-lexical-text="true">C</span></div></div><div><span data-lexical-text="true">B</span></div></p></div>',
1354       );
1355     });
1356   });
1357
1358   it('can subscribe and unsubscribe from commands and the callback is fired', () => {
1359     init();
1360
1361     const commandListener = jest.fn();
1362     const command = createCommand('TEST_COMMAND');
1363     const payload = 'testPayload';
1364     const removeCommandListener = editor.registerCommand(
1365       command,
1366       commandListener,
1367       COMMAND_PRIORITY_EDITOR,
1368     );
1369     editor.dispatchCommand(command, payload);
1370     editor.dispatchCommand(command, payload);
1371     editor.dispatchCommand(command, payload);
1372
1373     expect(commandListener).toHaveBeenCalledTimes(3);
1374     expect(commandListener).toHaveBeenCalledWith(payload, editor);
1375
1376     removeCommandListener();
1377
1378     editor.dispatchCommand(command, payload);
1379     editor.dispatchCommand(command, payload);
1380     editor.dispatchCommand(command, payload);
1381
1382     expect(commandListener).toHaveBeenCalledTimes(3);
1383     expect(commandListener).toHaveBeenCalledWith(payload, editor);
1384   });
1385
1386   it('removes the command from the command map when no listener are attached', () => {
1387     init();
1388
1389     const commandListener = jest.fn();
1390     const commandListenerTwo = jest.fn();
1391     const command = createCommand('TEST_COMMAND');
1392     const removeCommandListener = editor.registerCommand(
1393       command,
1394       commandListener,
1395       COMMAND_PRIORITY_EDITOR,
1396     );
1397     const removeCommandListenerTwo = editor.registerCommand(
1398       command,
1399       commandListenerTwo,
1400       COMMAND_PRIORITY_EDITOR,
1401     );
1402
1403     expect(editor._commands).toEqual(
1404       new Map([
1405         [
1406           command,
1407           [
1408             new Set([commandListener, commandListenerTwo]),
1409             new Set(),
1410             new Set(),
1411             new Set(),
1412             new Set(),
1413           ],
1414         ],
1415       ]),
1416     );
1417
1418     removeCommandListener();
1419
1420     expect(editor._commands).toEqual(
1421       new Map([
1422         [
1423           command,
1424           [
1425             new Set([commandListenerTwo]),
1426             new Set(),
1427             new Set(),
1428             new Set(),
1429             new Set(),
1430           ],
1431         ],
1432       ]),
1433     );
1434
1435     removeCommandListenerTwo();
1436
1437     expect(editor._commands).toEqual(new Map());
1438   });
1439
1440   it('can register transforms before updates', async () => {
1441     init();
1442
1443     const emptyTransform = () => {
1444       return;
1445     };
1446
1447     const removeTextTransform = editor.registerNodeTransform(
1448       TextNode,
1449       emptyTransform,
1450     );
1451     const removeParagraphTransform = editor.registerNodeTransform(
1452       ParagraphNode,
1453       emptyTransform,
1454     );
1455
1456     await editor.update(() => {
1457       const root = $getRoot();
1458       const paragraph = $createParagraphNode();
1459       root.append(paragraph);
1460     });
1461
1462     removeTextTransform();
1463     removeParagraphTransform();
1464   });
1465
1466   it('textcontent listener', async () => {
1467     init();
1468
1469     const fn = jest.fn();
1470     editor.update(() => {
1471       const root = $getRoot();
1472       const paragraph = $createParagraphNode();
1473       const textNode = $createTextNode('foo');
1474       root.append(paragraph);
1475       paragraph.append(textNode);
1476     });
1477     editor.registerTextContentListener((text) => {
1478       fn(text);
1479     });
1480
1481     await editor.update(() => {
1482       const root = $getRoot();
1483       const child = root.getLastDescendant()!;
1484       child.insertAfter($createTextNode('bar'));
1485     });
1486
1487     expect(fn).toHaveBeenCalledTimes(1);
1488     expect(fn).toHaveBeenCalledWith('foobar');
1489
1490     await editor.update(() => {
1491       const root = $getRoot();
1492       const child = root.getLastDescendant()!;
1493       child.insertAfter($createLineBreakNode());
1494     });
1495
1496     expect(fn).toHaveBeenCalledTimes(2);
1497     expect(fn).toHaveBeenCalledWith('foobar\n');
1498
1499     await editor.update(() => {
1500       const root = $getRoot();
1501       root.clear();
1502       const paragraph = $createParagraphNode();
1503       const paragraph2 = $createParagraphNode();
1504       root.append(paragraph);
1505       paragraph.append($createTextNode('bar'));
1506       paragraph2.append($createTextNode('yar'));
1507       paragraph.insertAfter(paragraph2);
1508     });
1509
1510     expect(fn).toHaveBeenCalledTimes(3);
1511     expect(fn).toHaveBeenCalledWith('bar\n\nyar');
1512
1513     await editor.update(() => {
1514       const root = $getRoot();
1515       const paragraph = $createParagraphNode();
1516       const paragraph2 = $createParagraphNode();
1517       root.getLastChild()!.insertAfter(paragraph);
1518       paragraph.append($createTextNode('bar2'));
1519       paragraph2.append($createTextNode('yar2'));
1520       paragraph.insertAfter(paragraph2);
1521     });
1522
1523     expect(fn).toHaveBeenCalledTimes(4);
1524     expect(fn).toHaveBeenCalledWith('bar\n\nyar\n\nbar2\n\nyar2');
1525   });
1526
1527   it('mutation listener', async () => {
1528     init();
1529
1530     const paragraphNodeMutations = jest.fn();
1531     const textNodeMutations = jest.fn();
1532     editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1533       skipInitialization: false,
1534     });
1535     editor.registerMutationListener(TextNode, textNodeMutations, {
1536       skipInitialization: false,
1537     });
1538     const paragraphKeys: string[] = [];
1539     const textNodeKeys: string[] = [];
1540
1541     // No await intentional (batch with next)
1542     editor.update(() => {
1543       const root = $getRoot();
1544       const paragraph = $createParagraphNode();
1545       const textNode = $createTextNode('foo');
1546       root.append(paragraph);
1547       paragraph.append(textNode);
1548       paragraphKeys.push(paragraph.getKey());
1549       textNodeKeys.push(textNode.getKey());
1550     });
1551
1552     await editor.update(() => {
1553       const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
1554       const textNode2 = $createTextNode('bar').toggleFormat('bold');
1555       const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1556       textNode.insertAfter(textNode2);
1557       textNode2.insertAfter(textNode3);
1558       textNodeKeys.push(textNode2.getKey());
1559       textNodeKeys.push(textNode3.getKey());
1560     });
1561
1562     await editor.update(() => {
1563       $getRoot().clear();
1564     });
1565
1566     await editor.update(() => {
1567       const root = $getRoot();
1568       const paragraph = $createParagraphNode();
1569
1570       paragraphKeys.push(paragraph.getKey());
1571
1572       // Created and deleted in the same update (not attached to node)
1573       textNodeKeys.push($createTextNode('zzz').getKey());
1574       root.append(paragraph);
1575     });
1576
1577     expect(paragraphNodeMutations.mock.calls.length).toBe(3);
1578     expect(textNodeMutations.mock.calls.length).toBe(2);
1579
1580     const [paragraphMutation1, paragraphMutation2, paragraphMutation3] =
1581       paragraphNodeMutations.mock.calls;
1582     const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
1583
1584     expect(paragraphMutation1[0].size).toBe(1);
1585     expect(paragraphMutation1[0].get(paragraphKeys[0])).toBe('created');
1586     expect(paragraphMutation1[0].size).toBe(1);
1587     expect(paragraphMutation2[0].get(paragraphKeys[0])).toBe('destroyed');
1588     expect(paragraphMutation3[0].size).toBe(1);
1589     expect(paragraphMutation3[0].get(paragraphKeys[1])).toBe('created');
1590     expect(textNodeMutation1[0].size).toBe(3);
1591     expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1592     expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
1593     expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
1594     expect(textNodeMutation2[0].size).toBe(3);
1595     expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1596     expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
1597     expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
1598   });
1599   it('mutation listener on newly initialized editor', async () => {
1600     editor = createEditor();
1601     const textNodeMutations = jest.fn();
1602     editor.registerMutationListener(TextNode, textNodeMutations, {
1603       skipInitialization: false,
1604     });
1605     expect(textNodeMutations.mock.calls.length).toBe(0);
1606   });
1607   it('mutation listener with setEditorState', async () => {
1608     init();
1609
1610     await editor.update(() => {
1611       $getRoot().append($createParagraphNode());
1612     });
1613
1614     const initialEditorState = editor.getEditorState();
1615     const textNodeMutations = jest.fn();
1616     editor.registerMutationListener(TextNode, textNodeMutations, {
1617       skipInitialization: false,
1618     });
1619     const textNodeKeys: string[] = [];
1620
1621     await editor.update(() => {
1622       const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1623       const textNode1 = $createTextNode('foo');
1624       paragraph.append(textNode1);
1625       textNodeKeys.push(textNode1.getKey());
1626     });
1627
1628     const fooEditorState = editor.getEditorState();
1629
1630     await editor.setEditorState(initialEditorState);
1631     // This line should have no effect on the mutation listeners
1632     const parsedFooEditorState = editor.parseEditorState(
1633       JSON.stringify(fooEditorState),
1634     );
1635
1636     await editor.update(() => {
1637       const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1638       const textNode2 = $createTextNode('bar').toggleFormat('bold');
1639       const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1640       paragraph.append(textNode2, textNode3);
1641       textNodeKeys.push(textNode2.getKey(), textNode3.getKey());
1642     });
1643
1644     await editor.setEditorState(parsedFooEditorState);
1645
1646     expect(textNodeMutations.mock.calls.length).toBe(4);
1647
1648     const [
1649       textNodeMutation1,
1650       textNodeMutation2,
1651       textNodeMutation3,
1652       textNodeMutation4,
1653     ] = textNodeMutations.mock.calls;
1654
1655     expect(textNodeMutation1[0].size).toBe(1);
1656     expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1657     expect(textNodeMutation2[0].size).toBe(1);
1658     expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1659     expect(textNodeMutation3[0].size).toBe(2);
1660     expect(textNodeMutation3[0].get(textNodeKeys[1])).toBe('created');
1661     expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('created');
1662     expect(textNodeMutation4[0].size).toBe(3); // +1 newly generated key by parseEditorState
1663     expect(textNodeMutation4[0].get(textNodeKeys[1])).toBe('destroyed');
1664     expect(textNodeMutation4[0].get(textNodeKeys[2])).toBe('destroyed');
1665   });
1666
1667   it('mutation listener set for original node should work with the replaced node', async () => {
1668
1669     function TestBase() {
1670       const edContainer = document.createElement('div');
1671       edContainer.contentEditable = 'true';
1672
1673       editor = useLexicalEditor(edContainer, undefined, [
1674         TestTextNode,
1675         {
1676           replace: TextNode,
1677           with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1678           withKlass: TestTextNode,
1679         },
1680       ]);
1681
1682       return edContainer;
1683     }
1684
1685     setContainerChild(TestBase());
1686
1687     const textNodeMutations = jest.fn();
1688     const textNodeMutationsB = jest.fn();
1689     editor.registerMutationListener(TextNode, textNodeMutations, {
1690       skipInitialization: false,
1691     });
1692     const textNodeKeys: string[] = [];
1693
1694     // No await intentional (batch with next)
1695     editor.update(() => {
1696       const root = $getRoot();
1697       const paragraph = $createParagraphNode();
1698       const textNode = $createTextNode('foo');
1699       root.append(paragraph);
1700       paragraph.append(textNode);
1701       textNodeKeys.push(textNode.getKey());
1702     });
1703
1704     await editor.update(() => {
1705       const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
1706       const textNode2 = $createTextNode('bar').toggleFormat('bold');
1707       const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1708       textNode.insertAfter(textNode2);
1709       textNode2.insertAfter(textNode3);
1710       textNodeKeys.push(textNode2.getKey());
1711       textNodeKeys.push(textNode3.getKey());
1712     });
1713
1714     editor.registerMutationListener(TextNode, textNodeMutationsB, {
1715       skipInitialization: false,
1716     });
1717
1718     await editor.update(() => {
1719       $getRoot().clear();
1720     });
1721
1722     await editor.update(() => {
1723       const root = $getRoot();
1724       const paragraph = $createParagraphNode();
1725
1726       // Created and deleted in the same update (not attached to node)
1727       textNodeKeys.push($createTextNode('zzz').getKey());
1728       root.append(paragraph);
1729     });
1730
1731     expect(textNodeMutations.mock.calls.length).toBe(2);
1732     expect(textNodeMutationsB.mock.calls.length).toBe(2);
1733
1734     const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
1735
1736     expect(textNodeMutation1[0].size).toBe(3);
1737     expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1738     expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
1739     expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
1740     expect([...textNodeMutation1[1].updateTags]).toEqual([]);
1741     expect(textNodeMutation2[0].size).toBe(3);
1742     expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1743     expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
1744     expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
1745     expect([...textNodeMutation2[1].updateTags]).toEqual([]);
1746
1747     const [textNodeMutationB1, textNodeMutationB2] =
1748       textNodeMutationsB.mock.calls;
1749
1750     expect(textNodeMutationB1[0].size).toBe(3);
1751     expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
1752     expect(textNodeMutationB1[0].get(textNodeKeys[1])).toBe('created');
1753     expect(textNodeMutationB1[0].get(textNodeKeys[2])).toBe('created');
1754     expect([...textNodeMutationB1[1].updateTags]).toEqual([
1755       'registerMutationListener',
1756     ]);
1757     expect(textNodeMutationB2[0].size).toBe(3);
1758     expect(textNodeMutationB2[0].get(textNodeKeys[0])).toBe('destroyed');
1759     expect(textNodeMutationB2[0].get(textNodeKeys[1])).toBe('destroyed');
1760     expect(textNodeMutationB2[0].get(textNodeKeys[2])).toBe('destroyed');
1761     expect([...textNodeMutationB2[1].updateTags]).toEqual([]);
1762   });
1763
1764   it('mutation listener should work with the replaced node', async () => {
1765
1766     function TestBase() {
1767       const edContainer = document.createElement('div');
1768       edContainer.contentEditable = 'true';
1769
1770       editor = useLexicalEditor(edContainer, undefined, [
1771         TestTextNode,
1772         {
1773           replace: TextNode,
1774           with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1775           withKlass: TestTextNode,
1776         },
1777       ]);
1778
1779       return edContainer;
1780     }
1781
1782     setContainerChild(TestBase());
1783
1784     const textNodeMutations = jest.fn();
1785     const textNodeMutationsB = jest.fn();
1786     editor.registerMutationListener(TestTextNode, textNodeMutations, {
1787       skipInitialization: false,
1788     });
1789     const textNodeKeys: string[] = [];
1790
1791     await editor.update(() => {
1792       const root = $getRoot();
1793       const paragraph = $createParagraphNode();
1794       const textNode = $createTextNode('foo');
1795       root.append(paragraph);
1796       paragraph.append(textNode);
1797       textNodeKeys.push(textNode.getKey());
1798     });
1799
1800     editor.registerMutationListener(TestTextNode, textNodeMutationsB, {
1801       skipInitialization: false,
1802     });
1803
1804     expect(textNodeMutations.mock.calls.length).toBe(1);
1805
1806     const [textNodeMutation1] = textNodeMutations.mock.calls;
1807
1808     expect(textNodeMutation1[0].size).toBe(1);
1809     expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1810     expect([...textNodeMutation1[1].updateTags]).toEqual([]);
1811
1812     const [textNodeMutationB1] = textNodeMutationsB.mock.calls;
1813
1814     expect(textNodeMutationB1[0].size).toBe(1);
1815     expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
1816     expect([...textNodeMutationB1[1].updateTags]).toEqual([
1817       'registerMutationListener',
1818     ]);
1819   });
1820
1821   it('mutation listeners does not trigger when other node types are mutated', async () => {
1822     init();
1823
1824     const paragraphNodeMutations = jest.fn();
1825     const textNodeMutations = jest.fn();
1826     editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1827       skipInitialization: false,
1828     });
1829     editor.registerMutationListener(TextNode, textNodeMutations, {
1830       skipInitialization: false,
1831     });
1832
1833     await editor.update(() => {
1834       $getRoot().append($createParagraphNode());
1835     });
1836
1837     expect(paragraphNodeMutations.mock.calls.length).toBe(1);
1838     expect(textNodeMutations.mock.calls.length).toBe(0);
1839   });
1840
1841   it('mutation listeners with normalization', async () => {
1842     init();
1843
1844     const textNodeMutations = jest.fn();
1845     editor.registerMutationListener(TextNode, textNodeMutations, {
1846       skipInitialization: false,
1847     });
1848     const textNodeKeys: string[] = [];
1849
1850     await editor.update(() => {
1851       const root = $getRoot();
1852       const paragraph = $createParagraphNode();
1853       const textNode1 = $createTextNode('foo');
1854       const textNode2 = $createTextNode('bar');
1855
1856       textNodeKeys.push(textNode1.getKey(), textNode2.getKey());
1857       root.append(paragraph);
1858       paragraph.append(textNode1, textNode2);
1859     });
1860
1861     await editor.update(() => {
1862       const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1863       const textNode3 = $createTextNode('xyz').toggleFormat('bold');
1864       paragraph.append(textNode3);
1865       textNodeKeys.push(textNode3.getKey());
1866     });
1867
1868     await editor.update(() => {
1869       const textNode3 = $getNodeByKey(textNodeKeys[2]) as TextNode;
1870       textNode3.toggleFormat('bold'); // Normalize with foobar
1871     });
1872
1873     expect(textNodeMutations.mock.calls.length).toBe(3);
1874
1875     const [textNodeMutation1, textNodeMutation2, textNodeMutation3] =
1876       textNodeMutations.mock.calls;
1877
1878     expect(textNodeMutation1[0].size).toBe(1);
1879     expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1880     expect(textNodeMutation2[0].size).toBe(2);
1881     expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('created');
1882     expect(textNodeMutation3[0].size).toBe(2);
1883     expect(textNodeMutation3[0].get(textNodeKeys[0])).toBe('updated');
1884     expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('destroyed');
1885   });
1886
1887   it('mutation "update" listener', async () => {
1888     init();
1889
1890     const paragraphNodeMutations = jest.fn();
1891     const textNodeMutations = jest.fn();
1892
1893     editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1894       skipInitialization: false,
1895     });
1896     editor.registerMutationListener(TextNode, textNodeMutations, {
1897       skipInitialization: false,
1898     });
1899
1900     const paragraphNodeKeys: string[] = [];
1901     const textNodeKeys: string[] = [];
1902
1903     await editor.update(() => {
1904       const root = $getRoot();
1905       const paragraph = $createParagraphNode();
1906       const textNode1 = $createTextNode('foo');
1907       textNodeKeys.push(textNode1.getKey());
1908       paragraphNodeKeys.push(paragraph.getKey());
1909       root.append(paragraph);
1910       paragraph.append(textNode1);
1911     });
1912
1913     expect(paragraphNodeMutations.mock.calls.length).toBe(1);
1914
1915     const [paragraphNodeMutation1] = paragraphNodeMutations.mock.calls;
1916     expect(textNodeMutations.mock.calls.length).toBe(1);
1917
1918     const [textNodeMutation1] = textNodeMutations.mock.calls;
1919
1920     expect(textNodeMutation1[0].size).toBe(1);
1921     expect(paragraphNodeMutation1[0].size).toBe(1);
1922
1923     // Change first text node's content.
1924     await editor.update(() => {
1925       const textNode1 = $getNodeByKey(textNodeKeys[0]) as TextNode;
1926       textNode1.setTextContent('Test'); // Normalize with foobar
1927     });
1928
1929     // Append text node to paragraph.
1930     await editor.update(() => {
1931       const paragraphNode1 = $getNodeByKey(
1932         paragraphNodeKeys[0],
1933       ) as ParagraphNode;
1934       const textNode1 = $createTextNode('foo');
1935       paragraphNode1.append(textNode1);
1936     });
1937
1938     expect(textNodeMutations.mock.calls.length).toBe(3);
1939
1940     const textNodeMutation2 = textNodeMutations.mock.calls[1];
1941
1942     // Show TextNode was updated when text content changed.
1943     expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('updated');
1944     expect(paragraphNodeMutations.mock.calls.length).toBe(2);
1945
1946     const paragraphNodeMutation2 = paragraphNodeMutations.mock.calls[1];
1947
1948     // Show ParagraphNode was updated when new text node was appended.
1949     expect(paragraphNodeMutation2[0].get(paragraphNodeKeys[0])).toBe('updated');
1950
1951     let tableCellKey: string;
1952     let tableRowKey: string;
1953
1954     const tableCellMutations = jest.fn();
1955     const tableRowMutations = jest.fn();
1956
1957     editor.registerMutationListener(TableCellNode, tableCellMutations, {
1958       skipInitialization: false,
1959     });
1960     editor.registerMutationListener(TableRowNode, tableRowMutations, {
1961       skipInitialization: false,
1962     });
1963     // Create Table
1964
1965     await editor.update(() => {
1966       const root = $getRoot();
1967       const tableCell = $createTableCellNode(0);
1968       const tableRow = $createTableRowNode();
1969       const table = $createTableNode();
1970
1971       tableRow.append(tableCell);
1972       table.append(tableRow);
1973       root.append(table);
1974
1975       tableRowKey = tableRow.getKey();
1976       tableCellKey = tableCell.getKey();
1977     });
1978     // Add New Table Cell To Row
1979
1980     await editor.update(() => {
1981       const tableRow = $getNodeByKey(tableRowKey) as TableRowNode;
1982       const tableCell = $createTableCellNode(0);
1983       tableRow.append(tableCell);
1984     });
1985
1986     // Update Table Cell
1987     await editor.update(() => {
1988       const tableCell = $getNodeByKey(tableCellKey) as TableCellNode;
1989       tableCell.toggleHeaderStyle(1);
1990     });
1991
1992     expect(tableCellMutations.mock.calls.length).toBe(3);
1993     const tableCellMutation3 = tableCellMutations.mock.calls[2];
1994
1995     // Show table cell is updated when header value changes.
1996     expect(tableCellMutation3[0].get(tableCellKey!)).toBe('updated');
1997     expect(tableRowMutations.mock.calls.length).toBe(2);
1998
1999     const tableRowMutation2 = tableRowMutations.mock.calls[1];
2000
2001     // Show row is updated when a new child is added.
2002     expect(tableRowMutation2[0].get(tableRowKey!)).toBe('updated');
2003   });
2004
2005   it('editable listener', () => {
2006     init();
2007
2008     const editableFn = jest.fn();
2009     editor.registerEditableListener(editableFn);
2010
2011     expect(editor.isEditable()).toBe(true);
2012
2013     editor.setEditable(false);
2014
2015     expect(editor.isEditable()).toBe(false);
2016
2017     editor.setEditable(true);
2018
2019     expect(editableFn.mock.calls).toEqual([[false], [true]]);
2020   });
2021
2022   it('does not add new listeners while triggering existing', async () => {
2023     const updateListener = jest.fn();
2024     const mutationListener = jest.fn();
2025     const nodeTransformListener = jest.fn();
2026     const textContentListener = jest.fn();
2027     const editableListener = jest.fn();
2028     const commandListener = jest.fn();
2029     const TEST_COMMAND = createCommand('TEST_COMMAND');
2030
2031     init();
2032
2033     editor.registerUpdateListener(() => {
2034       updateListener();
2035
2036       editor.registerUpdateListener(() => {
2037         updateListener();
2038       });
2039     });
2040
2041     editor.registerMutationListener(
2042       TextNode,
2043       (map) => {
2044         mutationListener();
2045         editor.registerMutationListener(
2046           TextNode,
2047           () => {
2048             mutationListener();
2049           },
2050           {skipInitialization: true},
2051         );
2052       },
2053       {skipInitialization: false},
2054     );
2055
2056     editor.registerNodeTransform(ParagraphNode, () => {
2057       nodeTransformListener();
2058       editor.registerNodeTransform(ParagraphNode, () => {
2059         nodeTransformListener();
2060       });
2061     });
2062
2063     editor.registerEditableListener(() => {
2064       editableListener();
2065       editor.registerEditableListener(() => {
2066         editableListener();
2067       });
2068     });
2069
2070     editor.registerTextContentListener(() => {
2071       textContentListener();
2072       editor.registerTextContentListener(() => {
2073         textContentListener();
2074       });
2075     });
2076
2077     editor.registerCommand(
2078       TEST_COMMAND,
2079       (): boolean => {
2080         commandListener();
2081         editor.registerCommand(
2082           TEST_COMMAND,
2083           commandListener,
2084           COMMAND_PRIORITY_LOW,
2085         );
2086         return false;
2087       },
2088       COMMAND_PRIORITY_LOW,
2089     );
2090
2091     await update(() => {
2092       $getRoot().append(
2093         $createParagraphNode().append($createTextNode('Hello world')),
2094       );
2095     });
2096
2097     editor.dispatchCommand(TEST_COMMAND, false);
2098
2099     editor.setEditable(false);
2100
2101     expect(updateListener).toHaveBeenCalledTimes(1);
2102     expect(editableListener).toHaveBeenCalledTimes(1);
2103     expect(commandListener).toHaveBeenCalledTimes(1);
2104     expect(textContentListener).toHaveBeenCalledTimes(1);
2105     expect(nodeTransformListener).toHaveBeenCalledTimes(1);
2106     expect(mutationListener).toHaveBeenCalledTimes(1);
2107   });
2108
2109   it('calls mutation listener with initial state', async () => {
2110     // TODO add tests for node replacement
2111     const mutationListenerA = jest.fn();
2112     const mutationListenerB = jest.fn();
2113     const mutationListenerC = jest.fn();
2114     init();
2115
2116     editor.registerMutationListener(TextNode, mutationListenerA, {
2117       skipInitialization: false,
2118     });
2119     expect(mutationListenerA).toHaveBeenCalledTimes(0);
2120
2121     await update(() => {
2122       $getRoot().append(
2123         $createParagraphNode().append($createTextNode('Hello world')),
2124       );
2125     });
2126
2127     function asymmetricMatcher<T>(asymmetricMatch: (x: T) => boolean) {
2128       return {asymmetricMatch};
2129     }
2130
2131     expect(mutationListenerA).toHaveBeenCalledTimes(1);
2132     expect(mutationListenerA).toHaveBeenLastCalledWith(
2133       expect.anything(),
2134       expect.objectContaining({
2135         updateTags: asymmetricMatcher(
2136           (s: Set<string>) => !s.has('registerMutationListener'),
2137         ),
2138       }),
2139     );
2140     editor.registerMutationListener(TextNode, mutationListenerB, {
2141       skipInitialization: false,
2142     });
2143     editor.registerMutationListener(TextNode, mutationListenerC, {
2144       skipInitialization: true,
2145     });
2146     expect(mutationListenerA).toHaveBeenCalledTimes(1);
2147     expect(mutationListenerB).toHaveBeenCalledTimes(1);
2148     expect(mutationListenerB).toHaveBeenLastCalledWith(
2149       expect.anything(),
2150       expect.objectContaining({
2151         updateTags: asymmetricMatcher((s: Set<string>) =>
2152           s.has('registerMutationListener'),
2153         ),
2154       }),
2155     );
2156     expect(mutationListenerC).toHaveBeenCalledTimes(0);
2157     await update(() => {
2158       $getRoot().append(
2159         $createParagraphNode().append($createTextNode('Another update!')),
2160       );
2161     });
2162     expect(mutationListenerA).toHaveBeenCalledTimes(2);
2163     expect(mutationListenerB).toHaveBeenCalledTimes(2);
2164     expect(mutationListenerC).toHaveBeenCalledTimes(1);
2165     [mutationListenerA, mutationListenerB, mutationListenerC].forEach((fn) => {
2166       expect(fn).toHaveBeenLastCalledWith(
2167         expect.anything(),
2168         expect.objectContaining({
2169           updateTags: asymmetricMatcher(
2170             (s: Set<string>) => !s.has('registerMutationListener'),
2171           ),
2172         }),
2173       );
2174     });
2175   });
2176
2177   it('can use discrete for synchronous updates', () => {
2178     init();
2179     const onUpdate = jest.fn();
2180     editor.registerUpdateListener(onUpdate);
2181     editor.update(
2182       () => {
2183         $getRoot().append(
2184           $createParagraphNode().append($createTextNode('Sync update')),
2185         );
2186       },
2187       {
2188         discrete: true,
2189       },
2190     );
2191
2192     const textContent = editor
2193       .getEditorState()
2194       .read(() => $getRoot().getTextContent());
2195     expect(textContent).toBe('Sync update');
2196     expect(onUpdate).toHaveBeenCalledTimes(1);
2197   });
2198
2199   it('can use discrete after a non-discrete update to flush the entire queue', () => {
2200     const headless = createTestHeadlessEditor();
2201     const onUpdate = jest.fn();
2202     headless.registerUpdateListener(onUpdate);
2203     headless.update(() => {
2204       $getRoot().append(
2205         $createParagraphNode().append($createTextNode('Async update')),
2206       );
2207     });
2208     headless.update(
2209       () => {
2210         $getRoot().append(
2211           $createParagraphNode().append($createTextNode('Sync update')),
2212         );
2213       },
2214       {
2215         discrete: true,
2216       },
2217     );
2218
2219     const textContent = headless
2220       .getEditorState()
2221       .read(() => $getRoot().getTextContent());
2222     expect(textContent).toBe('Async update\n\nSync update');
2223     expect(onUpdate).toHaveBeenCalledTimes(1);
2224   });
2225
2226   it('can use discrete after a non-discrete setEditorState to flush the entire queue', () => {
2227     init();
2228     editor.update(
2229       () => {
2230         $getRoot().append(
2231           $createParagraphNode().append($createTextNode('Async update')),
2232         );
2233       },
2234       {
2235         discrete: true,
2236       },
2237     );
2238
2239     const headless = createTestHeadlessEditor(editor.getEditorState());
2240     headless.update(
2241       () => {
2242         $getRoot().append(
2243           $createParagraphNode().append($createTextNode('Sync update')),
2244         );
2245       },
2246       {
2247         discrete: true,
2248       },
2249     );
2250     const textContent = headless
2251       .getEditorState()
2252       .read(() => $getRoot().getTextContent());
2253     expect(textContent).toBe('Async update\n\nSync update');
2254   });
2255
2256   it('can use discrete in a nested update to flush the entire queue', () => {
2257     init();
2258     const onUpdate = jest.fn();
2259     editor.registerUpdateListener(onUpdate);
2260     editor.update(() => {
2261       $getRoot().append(
2262         $createParagraphNode().append($createTextNode('Async update')),
2263       );
2264       editor.update(
2265         () => {
2266           $getRoot().append(
2267             $createParagraphNode().append($createTextNode('Sync update')),
2268           );
2269         },
2270         {
2271           discrete: true,
2272         },
2273       );
2274     });
2275
2276     const textContent = editor
2277       .getEditorState()
2278       .read(() => $getRoot().getTextContent());
2279     expect(textContent).toBe('Async update\n\nSync update');
2280     expect(onUpdate).toHaveBeenCalledTimes(1);
2281   });
2282
2283   it('does not include linebreak into inline elements', async () => {
2284     init();
2285
2286     await editor.update(() => {
2287       $getRoot().append(
2288         $createParagraphNode().append(
2289           $createTextNode('Hello'),
2290           $createTestInlineElementNode(),
2291         ),
2292       );
2293     });
2294
2295     expect(container.firstElementChild?.innerHTML).toBe(
2296       '<p><span data-lexical-text="true">Hello</span><a></a></p>',
2297     );
2298   });
2299
2300   it('reconciles state without root element', () => {
2301     editor = createTestEditor({});
2302     const state = editor.parseEditorState(
2303       `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`,
2304     );
2305     editor.setEditorState(state);
2306     expect(editor._editorState).toBe(state);
2307     expect(editor._pendingEditorState).toBe(null);
2308   });
2309
2310   describe('node replacement', () => {
2311     it('should work correctly', async () => {
2312       const onError = jest.fn();
2313
2314       const newEditor = createTestEditor({
2315         nodes: [
2316           TestTextNode,
2317           {
2318             replace: TextNode,
2319             with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2320           },
2321         ],
2322         onError: onError,
2323         theme: {
2324           text: {
2325             bold: 'editor-text-bold',
2326             italic: 'editor-text-italic',
2327             underline: 'editor-text-underline',
2328           },
2329         },
2330       });
2331
2332       newEditor.setRootElement(container);
2333
2334       await newEditor.update(() => {
2335         const root = $getRoot();
2336         const paragraph = $createParagraphNode();
2337         const text = $createTextNode('123');
2338         root.append(paragraph);
2339         paragraph.append(text);
2340         expect(text instanceof TestTextNode).toBe(true);
2341         expect(text.getTextContent()).toBe('123');
2342       });
2343
2344       expect(onError).not.toHaveBeenCalled();
2345     });
2346
2347     it('should fail if node keys are re-used', async () => {
2348       const onError = jest.fn();
2349
2350       const newEditor = createTestEditor({
2351         nodes: [
2352           TestTextNode,
2353           {
2354             replace: TextNode,
2355             with: (node: TextNode) =>
2356               new TestTextNode(node.getTextContent(), node.getKey()),
2357           },
2358         ],
2359         onError: onError,
2360         theme: {
2361           text: {
2362             bold: 'editor-text-bold',
2363             italic: 'editor-text-italic',
2364             underline: 'editor-text-underline',
2365           },
2366         },
2367       });
2368
2369       newEditor.setRootElement(container);
2370
2371       await newEditor.update(() => {
2372         // this will throw
2373         $createTextNode('123');
2374         expect(false).toBe('unreachable');
2375       });
2376
2377       newEditor.commitUpdates();
2378
2379       expect(onError).toHaveBeenCalledWith(
2380         expect.objectContaining({
2381           message: expect.stringMatching(/TestTextNode.*re-use key.*TextNode/),
2382         }),
2383       );
2384     });
2385
2386     it('node transform to the nodes specified by "replace" should not be applied to the nodes specified by "with" when "withKlass" is not specified', async () => {
2387       const onError = jest.fn();
2388
2389       const newEditor = createTestEditor({
2390         nodes: [
2391           TestTextNode,
2392           {
2393             replace: TextNode,
2394             with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2395           },
2396         ],
2397         onError: onError,
2398         theme: {
2399           text: {
2400             bold: 'editor-text-bold',
2401             italic: 'editor-text-italic',
2402             underline: 'editor-text-underline',
2403           },
2404         },
2405       });
2406
2407       newEditor.setRootElement(container);
2408
2409       const mockTransform = jest.fn();
2410       const removeTransform = newEditor.registerNodeTransform(
2411         TextNode,
2412         mockTransform,
2413       );
2414
2415       await newEditor.update(() => {
2416         const root = $getRoot();
2417         const paragraph = $createParagraphNode();
2418         const text = $createTextNode('123');
2419         root.append(paragraph);
2420         paragraph.append(text);
2421         expect(text instanceof TestTextNode).toBe(true);
2422         expect(text.getTextContent()).toBe('123');
2423       });
2424
2425       await newEditor.getEditorState().read(() => {
2426         expect(mockTransform).toHaveBeenCalledTimes(0);
2427       });
2428
2429       expect(onError).not.toHaveBeenCalled();
2430       removeTransform();
2431     });
2432
2433     it('node transform to the nodes specified by "replace" should be applied also to the nodes specified by "with" when "withKlass" is specified', async () => {
2434       const onError = jest.fn();
2435
2436       const newEditor = createTestEditor({
2437         nodes: [
2438           TestTextNode,
2439           {
2440             replace: TextNode,
2441             with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2442             withKlass: TestTextNode,
2443           },
2444         ],
2445         onError: onError,
2446         theme: {
2447           text: {
2448             bold: 'editor-text-bold',
2449             italic: 'editor-text-italic',
2450             underline: 'editor-text-underline',
2451           },
2452         },
2453       });
2454
2455       newEditor.setRootElement(container);
2456
2457       const mockTransform = jest.fn();
2458       const removeTransform = newEditor.registerNodeTransform(
2459         TextNode,
2460         mockTransform,
2461       );
2462
2463       await newEditor.update(() => {
2464         const root = $getRoot();
2465         const paragraph = $createParagraphNode();
2466         const text = $createTextNode('123');
2467         root.append(paragraph);
2468         paragraph.append(text);
2469         expect(text instanceof TestTextNode).toBe(true);
2470         expect(text.getTextContent()).toBe('123');
2471       });
2472
2473       await newEditor.getEditorState().read(() => {
2474         expect(mockTransform).toHaveBeenCalledTimes(1);
2475       });
2476
2477       expect(onError).not.toHaveBeenCalled();
2478       removeTransform();
2479     });
2480   });
2481
2482   it('recovers from reconciler failure and trigger proper prev editor state', async () => {
2483     const updateListener = jest.fn();
2484     const textListener = jest.fn();
2485     const onError = jest.fn();
2486     const updateError = new Error('Failed updateDOM');
2487
2488     init(onError);
2489
2490     editor.registerUpdateListener(updateListener);
2491     editor.registerTextContentListener(textListener);
2492
2493     await update(() => {
2494       $getRoot().append(
2495         $createParagraphNode().append($createTextNode('Hello')),
2496       );
2497     });
2498
2499     // Cause reconciler error in update dom, so that it attempts to fallback by
2500     // reseting editor and rerendering whole content
2501     jest.spyOn(ParagraphNode.prototype, 'updateDOM').mockImplementation(() => {
2502       throw updateError;
2503     });
2504
2505     const editorState = editor.getEditorState();
2506
2507     editor.registerUpdateListener(updateListener);
2508
2509     await update(() => {
2510       $getRoot().append(
2511         $createParagraphNode().append($createTextNode('world')),
2512       );
2513     });
2514
2515     expect(onError).toBeCalledWith(updateError);
2516     expect(textListener).toBeCalledWith('Hello\n\nworld');
2517     expect(updateListener.mock.lastCall[0].prevEditorState).toBe(editorState);
2518   });
2519
2520   it('should call importDOM methods only once', async () => {
2521     jest.spyOn(ParagraphNode, 'importDOM');
2522
2523     class CustomParagraphNode extends ParagraphNode {
2524       static getType() {
2525         return 'custom-paragraph';
2526       }
2527
2528       static clone(node: CustomParagraphNode) {
2529         return new CustomParagraphNode(node.__key);
2530       }
2531
2532       static importJSON() {
2533         return new CustomParagraphNode();
2534       }
2535
2536       exportJSON() {
2537         return {...super.exportJSON(), type: 'custom-paragraph'};
2538       }
2539     }
2540
2541     createTestEditor({nodes: [CustomParagraphNode]});
2542
2543     expect(ParagraphNode.importDOM).toHaveBeenCalledTimes(1);
2544   });
2545
2546   it('root element count is always positive', () => {
2547     const newEditor1 = createTestEditor();
2548     const newEditor2 = createTestEditor();
2549
2550     const container1 = document.createElement('div');
2551     const container2 = document.createElement('div');
2552
2553     newEditor1.setRootElement(container1);
2554     newEditor1.setRootElement(null);
2555
2556     newEditor1.setRootElement(container1);
2557     newEditor2.setRootElement(container2);
2558     newEditor1.setRootElement(null);
2559     newEditor2.setRootElement(null);
2560   });
2561
2562   describe('html config', () => {
2563     it('should override export output function', async () => {
2564       const onError = jest.fn();
2565
2566       const newEditor = createTestEditor({
2567         html: {
2568           export: new Map([
2569             [
2570               TextNode,
2571               (_, target) => {
2572                 invariant($isTextNode(target));
2573
2574                 return {
2575                   element: target.hasFormat('bold')
2576                     ? document.createElement('bor')
2577                     : document.createElement('foo'),
2578                 };
2579               },
2580             ],
2581           ]),
2582         },
2583         onError: onError,
2584       });
2585
2586       newEditor.setRootElement(container);
2587
2588       newEditor.update(() => {
2589         const root = $getRoot();
2590         const paragraph = $createParagraphNode();
2591         const text = $createTextNode();
2592         root.append(paragraph);
2593         paragraph.append(text);
2594
2595         const selection = $createNodeSelection();
2596         selection.add(text.getKey());
2597
2598         const htmlFoo = $generateHtmlFromNodes(newEditor, selection);
2599         expect(htmlFoo).toBe('<foo></foo>');
2600
2601         text.toggleFormat('bold');
2602
2603         const htmlBold = $generateHtmlFromNodes(newEditor, selection);
2604         expect(htmlBold).toBe('<bor></bor>');
2605       });
2606
2607       expect(onError).not.toHaveBeenCalled();
2608     });
2609
2610     it('should override import conversion function', async () => {
2611       const onError = jest.fn();
2612
2613       const newEditor = createTestEditor({
2614         html: {
2615           import: {
2616             figure: () => ({
2617               conversion: () => ({node: $createTextNode('yolo')}),
2618               priority: 4,
2619             }),
2620           },
2621         },
2622         onError: onError,
2623       });
2624
2625       newEditor.setRootElement(container);
2626
2627       newEditor.update(() => {
2628         const html = '<figure></figure>';
2629
2630         const parser = new DOMParser();
2631         const dom = parser.parseFromString(html, 'text/html');
2632         const node = $generateNodesFromDOM(newEditor, dom)[0];
2633
2634         expect(node).toEqual({
2635           __detail: 0,
2636           __format: 0,
2637           __key: node.getKey(),
2638           __mode: 0,
2639           __next: null,
2640           __parent: null,
2641           __prev: null,
2642           __style: '',
2643           __text: 'yolo',
2644           __type: 'text',
2645         });
2646       });
2647
2648       expect(onError).not.toHaveBeenCalled();
2649     });
2650   });
2651 });