]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts
5d763291972ab5de37c342009c5e28424350ce5c
[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,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"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           __key: 'root',
1051           __last: paragraphKey,
1052           __next: null,
1053           __parent: null,
1054           __prev: null,
1055           __size: 1,
1056           __style: '',
1057           __type: 'root',
1058         });
1059         expect(parsedParagraph).toEqual({
1060           "__alignment": "",
1061           __dir: null,
1062           __first: textKey,
1063           __id: '',
1064           __inset: 0,
1065           __key: paragraphKey,
1066           __last: textKey,
1067           __next: null,
1068           __parent: 'root',
1069           __prev: null,
1070           __size: 1,
1071           __style: '',
1072           __textStyle: '',
1073           __type: 'paragraph',
1074         });
1075         expect(parsedText).toEqual({
1076           __detail: 0,
1077           __format: 0,
1078           __key: textKey,
1079           __mode: 0,
1080           __next: null,
1081           __parent: paragraphKey,
1082           __prev: null,
1083           __style: '',
1084           __text: 'Hello world',
1085           __type: 'text',
1086         });
1087       });
1088
1089       it('Parses the text content of the editor state', async () => {
1090         expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1091           null,
1092         );
1093         expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1094           'Hello world',
1095         );
1096       });
1097     });
1098
1099     describe('node selection', () => {
1100       beforeEach(async () => {
1101         init();
1102
1103         await update(() => {
1104           const paragraph = $createParagraphNode();
1105           originalText = $createTextNode('Hello world');
1106           const selection = $createNodeSelection();
1107           selection.add(originalText.getKey());
1108           $setSelection(selection);
1109           paragraph.append(originalText);
1110           $getRoot().append(paragraph);
1111         });
1112         const stringifiedEditorState = JSON.stringify(
1113           editor.getEditorState().toJSON(),
1114         );
1115         parsedEditorState = editor.parseEditorState(stringifiedEditorState);
1116         parsedEditorState.read(() => {
1117           parsedRoot = $getRoot();
1118           parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode;
1119           paragraphKey = parsedParagraph.getKey();
1120           parsedText = parsedParagraph.getFirstChild() as TextNode;
1121           textKey = parsedText.getKey();
1122         });
1123       });
1124
1125       it('Parses the nodes of a stringified editor state', async () => {
1126         expect(parsedRoot).toEqual({
1127           __cachedText: null,
1128           __dir: null,
1129           __first: paragraphKey,
1130           __key: 'root',
1131           __last: paragraphKey,
1132           __next: null,
1133           __parent: null,
1134           __prev: null,
1135           __size: 1,
1136           __style: '',
1137           __type: 'root',
1138         });
1139         expect(parsedParagraph).toEqual({
1140           "__alignment": "",
1141           __dir: null,
1142           __first: textKey,
1143           __id: '',
1144           __inset: 0,
1145           __key: paragraphKey,
1146           __last: textKey,
1147           __next: null,
1148           __parent: 'root',
1149           __prev: null,
1150           __size: 1,
1151           __style: '',
1152           __textStyle: '',
1153           __type: 'paragraph',
1154         });
1155         expect(parsedText).toEqual({
1156           __detail: 0,
1157           __format: 0,
1158           __key: textKey,
1159           __mode: 0,
1160           __next: null,
1161           __parent: paragraphKey,
1162           __prev: null,
1163           __style: '',
1164           __text: 'Hello world',
1165           __type: 'text',
1166         });
1167       });
1168
1169       it('Parses the text content of the editor state', async () => {
1170         expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1171           null,
1172         );
1173         expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1174           'Hello world',
1175         );
1176       });
1177     });
1178   });
1179
1180   describe('$parseSerializedNode()', () => {
1181     it('parses serialized nodes', async () => {
1182       const expectedTextContent = 'Hello world\n\nHello world';
1183       let actualTextContent: string;
1184       let root: RootNode;
1185       await update(() => {
1186         root = $getRoot();
1187         root.clear();
1188         const paragraph = $createParagraphNode();
1189         paragraph.append($createTextNode('Hello world'));
1190         root.append(paragraph);
1191       });
1192       const stringifiedEditorState = JSON.stringify(editor.getEditorState());
1193       const parsedEditorStateJson = JSON.parse(stringifiedEditorState);
1194       const rootJson = parsedEditorStateJson.root;
1195       await update(() => {
1196         const children = rootJson.children.map($parseSerializedNode);
1197         root = $getRoot();
1198         root.append(...children);
1199         actualTextContent = root.getTextContent();
1200       });
1201       expect(actualTextContent!).toEqual(expectedTextContent);
1202     });
1203   });
1204
1205   describe('Node children', () => {
1206     beforeEach(async () => {
1207       init();
1208
1209       await reset();
1210     });
1211
1212     async function reset() {
1213       init();
1214
1215       await update(() => {
1216         const root = $getRoot();
1217         const paragraph = $createParagraphNode();
1218         root.append(paragraph);
1219       });
1220     }
1221
1222     it('moves node to different tree branches', async () => {
1223       function $createElementNodeWithText(text: string) {
1224         const elementNode = $createTestElementNode();
1225         const textNode = $createTextNode(text);
1226         elementNode.append(textNode);
1227
1228         return [elementNode, textNode];
1229       }
1230
1231       let paragraphNodeKey: string;
1232       let elementNode1Key: string;
1233       let textNode1Key: string;
1234       let elementNode2Key: string;
1235       let textNode2Key: string;
1236
1237       await update(() => {
1238         const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1239         paragraphNodeKey = paragraph.getKey();
1240
1241         const [elementNode1, textNode1] = $createElementNodeWithText('A');
1242         elementNode1Key = elementNode1.getKey();
1243         textNode1Key = textNode1.getKey();
1244
1245         const [elementNode2, textNode2] = $createElementNodeWithText('B');
1246         elementNode2Key = elementNode2.getKey();
1247         textNode2Key = textNode2.getKey();
1248
1249         paragraph.append(elementNode1, elementNode2);
1250       });
1251
1252       await update(() => {
1253         const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1254         const elementNode2 = $getNodeByKey(elementNode2Key) as TextNode;
1255         elementNode1.append(elementNode2);
1256       });
1257       const keys = [
1258         paragraphNodeKey!,
1259         elementNode1Key!,
1260         textNode1Key!,
1261         elementNode2Key!,
1262         textNode2Key!,
1263       ];
1264
1265       for (let i = 0; i < keys.length; i++) {
1266         expect(editor._editorState._nodeMap.has(keys[i])).toBe(true);
1267         expect(editor._keyToDOMMap.has(keys[i])).toBe(true);
1268       }
1269
1270       expect(editor._editorState._nodeMap.size).toBe(keys.length + 1); // + root
1271       expect(editor._keyToDOMMap.size).toBe(keys.length + 1); // + root
1272       expect(container.innerHTML).toBe(
1273         '<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>',
1274       );
1275     });
1276
1277     it('moves node to different tree branches (inverse)', async () => {
1278       function $createElementNodeWithText(text: string) {
1279         const elementNode = $createTestElementNode();
1280         const textNode = $createTextNode(text);
1281         elementNode.append(textNode);
1282
1283         return elementNode;
1284       }
1285
1286       let elementNode1Key: string;
1287       let elementNode2Key: string;
1288
1289       await update(() => {
1290         const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1291
1292         const elementNode1 = $createElementNodeWithText('A');
1293         elementNode1Key = elementNode1.getKey();
1294
1295         const elementNode2 = $createElementNodeWithText('B');
1296         elementNode2Key = elementNode2.getKey();
1297
1298         paragraph.append(elementNode1, elementNode2);
1299       });
1300
1301       await update(() => {
1302         const elementNode1 = $getNodeByKey(elementNode1Key) as TextNode;
1303         const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1304         elementNode2.append(elementNode1);
1305       });
1306
1307       expect(container.innerHTML).toBe(
1308         '<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>',
1309       );
1310     });
1311
1312     it('moves node to different tree branches (node appended twice in two different branches)', async () => {
1313       function $createElementNodeWithText(text: string) {
1314         const elementNode = $createTestElementNode();
1315         const textNode = $createTextNode(text);
1316         elementNode.append(textNode);
1317
1318         return elementNode;
1319       }
1320
1321       let elementNode1Key: string;
1322       let elementNode2Key: string;
1323       let elementNode3Key: string;
1324
1325       await update(() => {
1326         const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1327
1328         const elementNode1 = $createElementNodeWithText('A');
1329         elementNode1Key = elementNode1.getKey();
1330
1331         const elementNode2 = $createElementNodeWithText('B');
1332         elementNode2Key = elementNode2.getKey();
1333
1334         const elementNode3 = $createElementNodeWithText('C');
1335         elementNode3Key = elementNode3.getKey();
1336
1337         paragraph.append(elementNode1, elementNode2, elementNode3);
1338       });
1339
1340       await update(() => {
1341         const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1342         const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1343         const elementNode3 = $getNodeByKey(elementNode3Key) as TextNode;
1344         elementNode2.append(elementNode3);
1345         elementNode1.append(elementNode3);
1346       });
1347
1348       expect(container.innerHTML).toBe(
1349         '<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>',
1350       );
1351     });
1352   });
1353
1354   it('can subscribe and unsubscribe from commands and the callback is fired', () => {
1355     init();
1356
1357     const commandListener = jest.fn();
1358     const command = createCommand('TEST_COMMAND');
1359     const payload = 'testPayload';
1360     const removeCommandListener = editor.registerCommand(
1361       command,
1362       commandListener,
1363       COMMAND_PRIORITY_EDITOR,
1364     );
1365     editor.dispatchCommand(command, payload);
1366     editor.dispatchCommand(command, payload);
1367     editor.dispatchCommand(command, payload);
1368
1369     expect(commandListener).toHaveBeenCalledTimes(3);
1370     expect(commandListener).toHaveBeenCalledWith(payload, editor);
1371
1372     removeCommandListener();
1373
1374     editor.dispatchCommand(command, payload);
1375     editor.dispatchCommand(command, payload);
1376     editor.dispatchCommand(command, payload);
1377
1378     expect(commandListener).toHaveBeenCalledTimes(3);
1379     expect(commandListener).toHaveBeenCalledWith(payload, editor);
1380   });
1381
1382   it('removes the command from the command map when no listener are attached', () => {
1383     init();
1384
1385     const commandListener = jest.fn();
1386     const commandListenerTwo = jest.fn();
1387     const command = createCommand('TEST_COMMAND');
1388     const removeCommandListener = editor.registerCommand(
1389       command,
1390       commandListener,
1391       COMMAND_PRIORITY_EDITOR,
1392     );
1393     const removeCommandListenerTwo = editor.registerCommand(
1394       command,
1395       commandListenerTwo,
1396       COMMAND_PRIORITY_EDITOR,
1397     );
1398
1399     expect(editor._commands).toEqual(
1400       new Map([
1401         [
1402           command,
1403           [
1404             new Set([commandListener, commandListenerTwo]),
1405             new Set(),
1406             new Set(),
1407             new Set(),
1408             new Set(),
1409           ],
1410         ],
1411       ]),
1412     );
1413
1414     removeCommandListener();
1415
1416     expect(editor._commands).toEqual(
1417       new Map([
1418         [
1419           command,
1420           [
1421             new Set([commandListenerTwo]),
1422             new Set(),
1423             new Set(),
1424             new Set(),
1425             new Set(),
1426           ],
1427         ],
1428       ]),
1429     );
1430
1431     removeCommandListenerTwo();
1432
1433     expect(editor._commands).toEqual(new Map());
1434   });
1435
1436   it('can register transforms before updates', async () => {
1437     init();
1438
1439     const emptyTransform = () => {
1440       return;
1441     };
1442
1443     const removeTextTransform = editor.registerNodeTransform(
1444       TextNode,
1445       emptyTransform,
1446     );
1447     const removeParagraphTransform = editor.registerNodeTransform(
1448       ParagraphNode,
1449       emptyTransform,
1450     );
1451
1452     await editor.update(() => {
1453       const root = $getRoot();
1454       const paragraph = $createParagraphNode();
1455       root.append(paragraph);
1456     });
1457
1458     removeTextTransform();
1459     removeParagraphTransform();
1460   });
1461
1462   it('textcontent listener', async () => {
1463     init();
1464
1465     const fn = jest.fn();
1466     editor.update(() => {
1467       const root = $getRoot();
1468       const paragraph = $createParagraphNode();
1469       const textNode = $createTextNode('foo');
1470       root.append(paragraph);
1471       paragraph.append(textNode);
1472     });
1473     editor.registerTextContentListener((text) => {
1474       fn(text);
1475     });
1476
1477     await editor.update(() => {
1478       const root = $getRoot();
1479       const child = root.getLastDescendant()!;
1480       child.insertAfter($createTextNode('bar'));
1481     });
1482
1483     expect(fn).toHaveBeenCalledTimes(1);
1484     expect(fn).toHaveBeenCalledWith('foobar');
1485
1486     await editor.update(() => {
1487       const root = $getRoot();
1488       const child = root.getLastDescendant()!;
1489       child.insertAfter($createLineBreakNode());
1490     });
1491
1492     expect(fn).toHaveBeenCalledTimes(2);
1493     expect(fn).toHaveBeenCalledWith('foobar\n');
1494
1495     await editor.update(() => {
1496       const root = $getRoot();
1497       root.clear();
1498       const paragraph = $createParagraphNode();
1499       const paragraph2 = $createParagraphNode();
1500       root.append(paragraph);
1501       paragraph.append($createTextNode('bar'));
1502       paragraph2.append($createTextNode('yar'));
1503       paragraph.insertAfter(paragraph2);
1504     });
1505
1506     expect(fn).toHaveBeenCalledTimes(3);
1507     expect(fn).toHaveBeenCalledWith('bar\n\nyar');
1508
1509     await editor.update(() => {
1510       const root = $getRoot();
1511       const paragraph = $createParagraphNode();
1512       const paragraph2 = $createParagraphNode();
1513       root.getLastChild()!.insertAfter(paragraph);
1514       paragraph.append($createTextNode('bar2'));
1515       paragraph2.append($createTextNode('yar2'));
1516       paragraph.insertAfter(paragraph2);
1517     });
1518
1519     expect(fn).toHaveBeenCalledTimes(4);
1520     expect(fn).toHaveBeenCalledWith('bar\n\nyar\n\nbar2\n\nyar2');
1521   });
1522
1523   it('mutation listener', async () => {
1524     init();
1525
1526     const paragraphNodeMutations = jest.fn();
1527     const textNodeMutations = jest.fn();
1528     editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1529       skipInitialization: false,
1530     });
1531     editor.registerMutationListener(TextNode, textNodeMutations, {
1532       skipInitialization: false,
1533     });
1534     const paragraphKeys: string[] = [];
1535     const textNodeKeys: string[] = [];
1536
1537     // No await intentional (batch with next)
1538     editor.update(() => {
1539       const root = $getRoot();
1540       const paragraph = $createParagraphNode();
1541       const textNode = $createTextNode('foo');
1542       root.append(paragraph);
1543       paragraph.append(textNode);
1544       paragraphKeys.push(paragraph.getKey());
1545       textNodeKeys.push(textNode.getKey());
1546     });
1547
1548     await editor.update(() => {
1549       const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
1550       const textNode2 = $createTextNode('bar').toggleFormat('bold');
1551       const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1552       textNode.insertAfter(textNode2);
1553       textNode2.insertAfter(textNode3);
1554       textNodeKeys.push(textNode2.getKey());
1555       textNodeKeys.push(textNode3.getKey());
1556     });
1557
1558     await editor.update(() => {
1559       $getRoot().clear();
1560     });
1561
1562     await editor.update(() => {
1563       const root = $getRoot();
1564       const paragraph = $createParagraphNode();
1565
1566       paragraphKeys.push(paragraph.getKey());
1567
1568       // Created and deleted in the same update (not attached to node)
1569       textNodeKeys.push($createTextNode('zzz').getKey());
1570       root.append(paragraph);
1571     });
1572
1573     expect(paragraphNodeMutations.mock.calls.length).toBe(3);
1574     expect(textNodeMutations.mock.calls.length).toBe(2);
1575
1576     const [paragraphMutation1, paragraphMutation2, paragraphMutation3] =
1577       paragraphNodeMutations.mock.calls;
1578     const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
1579
1580     expect(paragraphMutation1[0].size).toBe(1);
1581     expect(paragraphMutation1[0].get(paragraphKeys[0])).toBe('created');
1582     expect(paragraphMutation1[0].size).toBe(1);
1583     expect(paragraphMutation2[0].get(paragraphKeys[0])).toBe('destroyed');
1584     expect(paragraphMutation3[0].size).toBe(1);
1585     expect(paragraphMutation3[0].get(paragraphKeys[1])).toBe('created');
1586     expect(textNodeMutation1[0].size).toBe(3);
1587     expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1588     expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
1589     expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
1590     expect(textNodeMutation2[0].size).toBe(3);
1591     expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1592     expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
1593     expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
1594   });
1595   it('mutation listener on newly initialized editor', async () => {
1596     editor = createEditor();
1597     const textNodeMutations = jest.fn();
1598     editor.registerMutationListener(TextNode, textNodeMutations, {
1599       skipInitialization: false,
1600     });
1601     expect(textNodeMutations.mock.calls.length).toBe(0);
1602   });
1603   it('mutation listener with setEditorState', async () => {
1604     init();
1605
1606     await editor.update(() => {
1607       $getRoot().append($createParagraphNode());
1608     });
1609
1610     const initialEditorState = editor.getEditorState();
1611     const textNodeMutations = jest.fn();
1612     editor.registerMutationListener(TextNode, textNodeMutations, {
1613       skipInitialization: false,
1614     });
1615     const textNodeKeys: string[] = [];
1616
1617     await editor.update(() => {
1618       const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1619       const textNode1 = $createTextNode('foo');
1620       paragraph.append(textNode1);
1621       textNodeKeys.push(textNode1.getKey());
1622     });
1623
1624     const fooEditorState = editor.getEditorState();
1625
1626     await editor.setEditorState(initialEditorState);
1627     // This line should have no effect on the mutation listeners
1628     const parsedFooEditorState = editor.parseEditorState(
1629       JSON.stringify(fooEditorState),
1630     );
1631
1632     await editor.update(() => {
1633       const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1634       const textNode2 = $createTextNode('bar').toggleFormat('bold');
1635       const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1636       paragraph.append(textNode2, textNode3);
1637       textNodeKeys.push(textNode2.getKey(), textNode3.getKey());
1638     });
1639
1640     await editor.setEditorState(parsedFooEditorState);
1641
1642     expect(textNodeMutations.mock.calls.length).toBe(4);
1643
1644     const [
1645       textNodeMutation1,
1646       textNodeMutation2,
1647       textNodeMutation3,
1648       textNodeMutation4,
1649     ] = textNodeMutations.mock.calls;
1650
1651     expect(textNodeMutation1[0].size).toBe(1);
1652     expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1653     expect(textNodeMutation2[0].size).toBe(1);
1654     expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1655     expect(textNodeMutation3[0].size).toBe(2);
1656     expect(textNodeMutation3[0].get(textNodeKeys[1])).toBe('created');
1657     expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('created');
1658     expect(textNodeMutation4[0].size).toBe(3); // +1 newly generated key by parseEditorState
1659     expect(textNodeMutation4[0].get(textNodeKeys[1])).toBe('destroyed');
1660     expect(textNodeMutation4[0].get(textNodeKeys[2])).toBe('destroyed');
1661   });
1662
1663   it('mutation listener set for original node should work with the replaced node', async () => {
1664
1665     function TestBase() {
1666       const edContainer = document.createElement('div');
1667       edContainer.contentEditable = 'true';
1668
1669       editor = useLexicalEditor(edContainer, undefined, [
1670         TestTextNode,
1671         {
1672           replace: TextNode,
1673           with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1674           withKlass: TestTextNode,
1675         },
1676       ]);
1677
1678       return edContainer;
1679     }
1680
1681     setContainerChild(TestBase());
1682
1683     const textNodeMutations = jest.fn();
1684     const textNodeMutationsB = jest.fn();
1685     editor.registerMutationListener(TextNode, textNodeMutations, {
1686       skipInitialization: false,
1687     });
1688     const textNodeKeys: string[] = [];
1689
1690     // No await intentional (batch with next)
1691     editor.update(() => {
1692       const root = $getRoot();
1693       const paragraph = $createParagraphNode();
1694       const textNode = $createTextNode('foo');
1695       root.append(paragraph);
1696       paragraph.append(textNode);
1697       textNodeKeys.push(textNode.getKey());
1698     });
1699
1700     await editor.update(() => {
1701       const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode;
1702       const textNode2 = $createTextNode('bar').toggleFormat('bold');
1703       const textNode3 = $createTextNode('xyz').toggleFormat('italic');
1704       textNode.insertAfter(textNode2);
1705       textNode2.insertAfter(textNode3);
1706       textNodeKeys.push(textNode2.getKey());
1707       textNodeKeys.push(textNode3.getKey());
1708     });
1709
1710     editor.registerMutationListener(TextNode, textNodeMutationsB, {
1711       skipInitialization: false,
1712     });
1713
1714     await editor.update(() => {
1715       $getRoot().clear();
1716     });
1717
1718     await editor.update(() => {
1719       const root = $getRoot();
1720       const paragraph = $createParagraphNode();
1721
1722       // Created and deleted in the same update (not attached to node)
1723       textNodeKeys.push($createTextNode('zzz').getKey());
1724       root.append(paragraph);
1725     });
1726
1727     expect(textNodeMutations.mock.calls.length).toBe(2);
1728     expect(textNodeMutationsB.mock.calls.length).toBe(2);
1729
1730     const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
1731
1732     expect(textNodeMutation1[0].size).toBe(3);
1733     expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1734     expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created');
1735     expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created');
1736     expect([...textNodeMutation1[1].updateTags]).toEqual([]);
1737     expect(textNodeMutation2[0].size).toBe(3);
1738     expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed');
1739     expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed');
1740     expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed');
1741     expect([...textNodeMutation2[1].updateTags]).toEqual([]);
1742
1743     const [textNodeMutationB1, textNodeMutationB2] =
1744       textNodeMutationsB.mock.calls;
1745
1746     expect(textNodeMutationB1[0].size).toBe(3);
1747     expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
1748     expect(textNodeMutationB1[0].get(textNodeKeys[1])).toBe('created');
1749     expect(textNodeMutationB1[0].get(textNodeKeys[2])).toBe('created');
1750     expect([...textNodeMutationB1[1].updateTags]).toEqual([
1751       'registerMutationListener',
1752     ]);
1753     expect(textNodeMutationB2[0].size).toBe(3);
1754     expect(textNodeMutationB2[0].get(textNodeKeys[0])).toBe('destroyed');
1755     expect(textNodeMutationB2[0].get(textNodeKeys[1])).toBe('destroyed');
1756     expect(textNodeMutationB2[0].get(textNodeKeys[2])).toBe('destroyed');
1757     expect([...textNodeMutationB2[1].updateTags]).toEqual([]);
1758   });
1759
1760   it('mutation listener should work with the replaced node', async () => {
1761
1762     function TestBase() {
1763       const edContainer = document.createElement('div');
1764       edContainer.contentEditable = 'true';
1765
1766       editor = useLexicalEditor(edContainer, undefined, [
1767         TestTextNode,
1768         {
1769           replace: TextNode,
1770           with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1771           withKlass: TestTextNode,
1772         },
1773       ]);
1774
1775       return edContainer;
1776     }
1777
1778     setContainerChild(TestBase());
1779
1780     const textNodeMutations = jest.fn();
1781     const textNodeMutationsB = jest.fn();
1782     editor.registerMutationListener(TestTextNode, textNodeMutations, {
1783       skipInitialization: false,
1784     });
1785     const textNodeKeys: string[] = [];
1786
1787     await editor.update(() => {
1788       const root = $getRoot();
1789       const paragraph = $createParagraphNode();
1790       const textNode = $createTextNode('foo');
1791       root.append(paragraph);
1792       paragraph.append(textNode);
1793       textNodeKeys.push(textNode.getKey());
1794     });
1795
1796     editor.registerMutationListener(TestTextNode, textNodeMutationsB, {
1797       skipInitialization: false,
1798     });
1799
1800     expect(textNodeMutations.mock.calls.length).toBe(1);
1801
1802     const [textNodeMutation1] = textNodeMutations.mock.calls;
1803
1804     expect(textNodeMutation1[0].size).toBe(1);
1805     expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1806     expect([...textNodeMutation1[1].updateTags]).toEqual([]);
1807
1808     const [textNodeMutationB1] = textNodeMutationsB.mock.calls;
1809
1810     expect(textNodeMutationB1[0].size).toBe(1);
1811     expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
1812     expect([...textNodeMutationB1[1].updateTags]).toEqual([
1813       'registerMutationListener',
1814     ]);
1815   });
1816
1817   it('mutation listeners does not trigger when other node types are mutated', async () => {
1818     init();
1819
1820     const paragraphNodeMutations = jest.fn();
1821     const textNodeMutations = jest.fn();
1822     editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1823       skipInitialization: false,
1824     });
1825     editor.registerMutationListener(TextNode, textNodeMutations, {
1826       skipInitialization: false,
1827     });
1828
1829     await editor.update(() => {
1830       $getRoot().append($createParagraphNode());
1831     });
1832
1833     expect(paragraphNodeMutations.mock.calls.length).toBe(1);
1834     expect(textNodeMutations.mock.calls.length).toBe(0);
1835   });
1836
1837   it('mutation listeners with normalization', async () => {
1838     init();
1839
1840     const textNodeMutations = jest.fn();
1841     editor.registerMutationListener(TextNode, textNodeMutations, {
1842       skipInitialization: false,
1843     });
1844     const textNodeKeys: string[] = [];
1845
1846     await editor.update(() => {
1847       const root = $getRoot();
1848       const paragraph = $createParagraphNode();
1849       const textNode1 = $createTextNode('foo');
1850       const textNode2 = $createTextNode('bar');
1851
1852       textNodeKeys.push(textNode1.getKey(), textNode2.getKey());
1853       root.append(paragraph);
1854       paragraph.append(textNode1, textNode2);
1855     });
1856
1857     await editor.update(() => {
1858       const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1859       const textNode3 = $createTextNode('xyz').toggleFormat('bold');
1860       paragraph.append(textNode3);
1861       textNodeKeys.push(textNode3.getKey());
1862     });
1863
1864     await editor.update(() => {
1865       const textNode3 = $getNodeByKey(textNodeKeys[2]) as TextNode;
1866       textNode3.toggleFormat('bold'); // Normalize with foobar
1867     });
1868
1869     expect(textNodeMutations.mock.calls.length).toBe(3);
1870
1871     const [textNodeMutation1, textNodeMutation2, textNodeMutation3] =
1872       textNodeMutations.mock.calls;
1873
1874     expect(textNodeMutation1[0].size).toBe(1);
1875     expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
1876     expect(textNodeMutation2[0].size).toBe(2);
1877     expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('created');
1878     expect(textNodeMutation3[0].size).toBe(2);
1879     expect(textNodeMutation3[0].get(textNodeKeys[0])).toBe('updated');
1880     expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('destroyed');
1881   });
1882
1883   it('mutation "update" listener', async () => {
1884     init();
1885
1886     const paragraphNodeMutations = jest.fn();
1887     const textNodeMutations = jest.fn();
1888
1889     editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1890       skipInitialization: false,
1891     });
1892     editor.registerMutationListener(TextNode, textNodeMutations, {
1893       skipInitialization: false,
1894     });
1895
1896     const paragraphNodeKeys: string[] = [];
1897     const textNodeKeys: string[] = [];
1898
1899     await editor.update(() => {
1900       const root = $getRoot();
1901       const paragraph = $createParagraphNode();
1902       const textNode1 = $createTextNode('foo');
1903       textNodeKeys.push(textNode1.getKey());
1904       paragraphNodeKeys.push(paragraph.getKey());
1905       root.append(paragraph);
1906       paragraph.append(textNode1);
1907     });
1908
1909     expect(paragraphNodeMutations.mock.calls.length).toBe(1);
1910
1911     const [paragraphNodeMutation1] = paragraphNodeMutations.mock.calls;
1912     expect(textNodeMutations.mock.calls.length).toBe(1);
1913
1914     const [textNodeMutation1] = textNodeMutations.mock.calls;
1915
1916     expect(textNodeMutation1[0].size).toBe(1);
1917     expect(paragraphNodeMutation1[0].size).toBe(1);
1918
1919     // Change first text node's content.
1920     await editor.update(() => {
1921       const textNode1 = $getNodeByKey(textNodeKeys[0]) as TextNode;
1922       textNode1.setTextContent('Test'); // Normalize with foobar
1923     });
1924
1925     // Append text node to paragraph.
1926     await editor.update(() => {
1927       const paragraphNode1 = $getNodeByKey(
1928         paragraphNodeKeys[0],
1929       ) as ParagraphNode;
1930       const textNode1 = $createTextNode('foo');
1931       paragraphNode1.append(textNode1);
1932     });
1933
1934     expect(textNodeMutations.mock.calls.length).toBe(3);
1935
1936     const textNodeMutation2 = textNodeMutations.mock.calls[1];
1937
1938     // Show TextNode was updated when text content changed.
1939     expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('updated');
1940     expect(paragraphNodeMutations.mock.calls.length).toBe(2);
1941
1942     const paragraphNodeMutation2 = paragraphNodeMutations.mock.calls[1];
1943
1944     // Show ParagraphNode was updated when new text node was appended.
1945     expect(paragraphNodeMutation2[0].get(paragraphNodeKeys[0])).toBe('updated');
1946
1947     let tableCellKey: string;
1948     let tableRowKey: string;
1949
1950     const tableCellMutations = jest.fn();
1951     const tableRowMutations = jest.fn();
1952
1953     editor.registerMutationListener(TableCellNode, tableCellMutations, {
1954       skipInitialization: false,
1955     });
1956     editor.registerMutationListener(TableRowNode, tableRowMutations, {
1957       skipInitialization: false,
1958     });
1959     // Create Table
1960
1961     await editor.update(() => {
1962       const root = $getRoot();
1963       const tableCell = $createTableCellNode(0);
1964       const tableRow = $createTableRowNode();
1965       const table = $createTableNode();
1966
1967       tableRow.append(tableCell);
1968       table.append(tableRow);
1969       root.append(table);
1970
1971       tableRowKey = tableRow.getKey();
1972       tableCellKey = tableCell.getKey();
1973     });
1974     // Add New Table Cell To Row
1975
1976     await editor.update(() => {
1977       const tableRow = $getNodeByKey(tableRowKey) as TableRowNode;
1978       const tableCell = $createTableCellNode(0);
1979       tableRow.append(tableCell);
1980     });
1981
1982     // Update Table Cell
1983     await editor.update(() => {
1984       const tableCell = $getNodeByKey(tableCellKey) as TableCellNode;
1985       tableCell.toggleHeaderStyle(1);
1986     });
1987
1988     expect(tableCellMutations.mock.calls.length).toBe(3);
1989     const tableCellMutation3 = tableCellMutations.mock.calls[2];
1990
1991     // Show table cell is updated when header value changes.
1992     expect(tableCellMutation3[0].get(tableCellKey!)).toBe('updated');
1993     expect(tableRowMutations.mock.calls.length).toBe(2);
1994
1995     const tableRowMutation2 = tableRowMutations.mock.calls[1];
1996
1997     // Show row is updated when a new child is added.
1998     expect(tableRowMutation2[0].get(tableRowKey!)).toBe('updated');
1999   });
2000
2001   it('editable listener', () => {
2002     init();
2003
2004     const editableFn = jest.fn();
2005     editor.registerEditableListener(editableFn);
2006
2007     expect(editor.isEditable()).toBe(true);
2008
2009     editor.setEditable(false);
2010
2011     expect(editor.isEditable()).toBe(false);
2012
2013     editor.setEditable(true);
2014
2015     expect(editableFn.mock.calls).toEqual([[false], [true]]);
2016   });
2017
2018   it('does not add new listeners while triggering existing', async () => {
2019     const updateListener = jest.fn();
2020     const mutationListener = jest.fn();
2021     const nodeTransformListener = jest.fn();
2022     const textContentListener = jest.fn();
2023     const editableListener = jest.fn();
2024     const commandListener = jest.fn();
2025     const TEST_COMMAND = createCommand('TEST_COMMAND');
2026
2027     init();
2028
2029     editor.registerUpdateListener(() => {
2030       updateListener();
2031
2032       editor.registerUpdateListener(() => {
2033         updateListener();
2034       });
2035     });
2036
2037     editor.registerMutationListener(
2038       TextNode,
2039       (map) => {
2040         mutationListener();
2041         editor.registerMutationListener(
2042           TextNode,
2043           () => {
2044             mutationListener();
2045           },
2046           {skipInitialization: true},
2047         );
2048       },
2049       {skipInitialization: false},
2050     );
2051
2052     editor.registerNodeTransform(ParagraphNode, () => {
2053       nodeTransformListener();
2054       editor.registerNodeTransform(ParagraphNode, () => {
2055         nodeTransformListener();
2056       });
2057     });
2058
2059     editor.registerEditableListener(() => {
2060       editableListener();
2061       editor.registerEditableListener(() => {
2062         editableListener();
2063       });
2064     });
2065
2066     editor.registerTextContentListener(() => {
2067       textContentListener();
2068       editor.registerTextContentListener(() => {
2069         textContentListener();
2070       });
2071     });
2072
2073     editor.registerCommand(
2074       TEST_COMMAND,
2075       (): boolean => {
2076         commandListener();
2077         editor.registerCommand(
2078           TEST_COMMAND,
2079           commandListener,
2080           COMMAND_PRIORITY_LOW,
2081         );
2082         return false;
2083       },
2084       COMMAND_PRIORITY_LOW,
2085     );
2086
2087     await update(() => {
2088       $getRoot().append(
2089         $createParagraphNode().append($createTextNode('Hello world')),
2090       );
2091     });
2092
2093     editor.dispatchCommand(TEST_COMMAND, false);
2094
2095     editor.setEditable(false);
2096
2097     expect(updateListener).toHaveBeenCalledTimes(1);
2098     expect(editableListener).toHaveBeenCalledTimes(1);
2099     expect(commandListener).toHaveBeenCalledTimes(1);
2100     expect(textContentListener).toHaveBeenCalledTimes(1);
2101     expect(nodeTransformListener).toHaveBeenCalledTimes(1);
2102     expect(mutationListener).toHaveBeenCalledTimes(1);
2103   });
2104
2105   it('calls mutation listener with initial state', async () => {
2106     // TODO add tests for node replacement
2107     const mutationListenerA = jest.fn();
2108     const mutationListenerB = jest.fn();
2109     const mutationListenerC = jest.fn();
2110     init();
2111
2112     editor.registerMutationListener(TextNode, mutationListenerA, {
2113       skipInitialization: false,
2114     });
2115     expect(mutationListenerA).toHaveBeenCalledTimes(0);
2116
2117     await update(() => {
2118       $getRoot().append(
2119         $createParagraphNode().append($createTextNode('Hello world')),
2120       );
2121     });
2122
2123     function asymmetricMatcher<T>(asymmetricMatch: (x: T) => boolean) {
2124       return {asymmetricMatch};
2125     }
2126
2127     expect(mutationListenerA).toHaveBeenCalledTimes(1);
2128     expect(mutationListenerA).toHaveBeenLastCalledWith(
2129       expect.anything(),
2130       expect.objectContaining({
2131         updateTags: asymmetricMatcher(
2132           (s: Set<string>) => !s.has('registerMutationListener'),
2133         ),
2134       }),
2135     );
2136     editor.registerMutationListener(TextNode, mutationListenerB, {
2137       skipInitialization: false,
2138     });
2139     editor.registerMutationListener(TextNode, mutationListenerC, {
2140       skipInitialization: true,
2141     });
2142     expect(mutationListenerA).toHaveBeenCalledTimes(1);
2143     expect(mutationListenerB).toHaveBeenCalledTimes(1);
2144     expect(mutationListenerB).toHaveBeenLastCalledWith(
2145       expect.anything(),
2146       expect.objectContaining({
2147         updateTags: asymmetricMatcher((s: Set<string>) =>
2148           s.has('registerMutationListener'),
2149         ),
2150       }),
2151     );
2152     expect(mutationListenerC).toHaveBeenCalledTimes(0);
2153     await update(() => {
2154       $getRoot().append(
2155         $createParagraphNode().append($createTextNode('Another update!')),
2156       );
2157     });
2158     expect(mutationListenerA).toHaveBeenCalledTimes(2);
2159     expect(mutationListenerB).toHaveBeenCalledTimes(2);
2160     expect(mutationListenerC).toHaveBeenCalledTimes(1);
2161     [mutationListenerA, mutationListenerB, mutationListenerC].forEach((fn) => {
2162       expect(fn).toHaveBeenLastCalledWith(
2163         expect.anything(),
2164         expect.objectContaining({
2165           updateTags: asymmetricMatcher(
2166             (s: Set<string>) => !s.has('registerMutationListener'),
2167           ),
2168         }),
2169       );
2170     });
2171   });
2172
2173   it('can use discrete for synchronous updates', () => {
2174     init();
2175     const onUpdate = jest.fn();
2176     editor.registerUpdateListener(onUpdate);
2177     editor.update(
2178       () => {
2179         $getRoot().append(
2180           $createParagraphNode().append($createTextNode('Sync update')),
2181         );
2182       },
2183       {
2184         discrete: true,
2185       },
2186     );
2187
2188     const textContent = editor
2189       .getEditorState()
2190       .read(() => $getRoot().getTextContent());
2191     expect(textContent).toBe('Sync update');
2192     expect(onUpdate).toHaveBeenCalledTimes(1);
2193   });
2194
2195   it('can use discrete after a non-discrete update to flush the entire queue', () => {
2196     const headless = createTestHeadlessEditor();
2197     const onUpdate = jest.fn();
2198     headless.registerUpdateListener(onUpdate);
2199     headless.update(() => {
2200       $getRoot().append(
2201         $createParagraphNode().append($createTextNode('Async update')),
2202       );
2203     });
2204     headless.update(
2205       () => {
2206         $getRoot().append(
2207           $createParagraphNode().append($createTextNode('Sync update')),
2208         );
2209       },
2210       {
2211         discrete: true,
2212       },
2213     );
2214
2215     const textContent = headless
2216       .getEditorState()
2217       .read(() => $getRoot().getTextContent());
2218     expect(textContent).toBe('Async update\n\nSync update');
2219     expect(onUpdate).toHaveBeenCalledTimes(1);
2220   });
2221
2222   it('can use discrete after a non-discrete setEditorState to flush the entire queue', () => {
2223     init();
2224     editor.update(
2225       () => {
2226         $getRoot().append(
2227           $createParagraphNode().append($createTextNode('Async update')),
2228         );
2229       },
2230       {
2231         discrete: true,
2232       },
2233     );
2234
2235     const headless = createTestHeadlessEditor(editor.getEditorState());
2236     headless.update(
2237       () => {
2238         $getRoot().append(
2239           $createParagraphNode().append($createTextNode('Sync update')),
2240         );
2241       },
2242       {
2243         discrete: true,
2244       },
2245     );
2246     const textContent = headless
2247       .getEditorState()
2248       .read(() => $getRoot().getTextContent());
2249     expect(textContent).toBe('Async update\n\nSync update');
2250   });
2251
2252   it('can use discrete in a nested update to flush the entire queue', () => {
2253     init();
2254     const onUpdate = jest.fn();
2255     editor.registerUpdateListener(onUpdate);
2256     editor.update(() => {
2257       $getRoot().append(
2258         $createParagraphNode().append($createTextNode('Async update')),
2259       );
2260       editor.update(
2261         () => {
2262           $getRoot().append(
2263             $createParagraphNode().append($createTextNode('Sync update')),
2264           );
2265         },
2266         {
2267           discrete: true,
2268         },
2269       );
2270     });
2271
2272     const textContent = editor
2273       .getEditorState()
2274       .read(() => $getRoot().getTextContent());
2275     expect(textContent).toBe('Async update\n\nSync update');
2276     expect(onUpdate).toHaveBeenCalledTimes(1);
2277   });
2278
2279   it('does not include linebreak into inline elements', async () => {
2280     init();
2281
2282     await editor.update(() => {
2283       $getRoot().append(
2284         $createParagraphNode().append(
2285           $createTextNode('Hello'),
2286           $createTestInlineElementNode(),
2287         ),
2288       );
2289     });
2290
2291     expect(container.firstElementChild?.innerHTML).toBe(
2292       '<p><span data-lexical-text="true">Hello</span><a></a></p>',
2293     );
2294   });
2295
2296   it('reconciles state without root element', () => {
2297     editor = createTestEditor({});
2298     const state = editor.parseEditorState(
2299       `{"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}}`,
2300     );
2301     editor.setEditorState(state);
2302     expect(editor._editorState).toBe(state);
2303     expect(editor._pendingEditorState).toBe(null);
2304   });
2305
2306   describe('node replacement', () => {
2307     it('should work correctly', async () => {
2308       const onError = jest.fn();
2309
2310       const newEditor = createTestEditor({
2311         nodes: [
2312           TestTextNode,
2313           {
2314             replace: TextNode,
2315             with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2316           },
2317         ],
2318         onError: onError,
2319         theme: {
2320           text: {
2321             bold: 'editor-text-bold',
2322             italic: 'editor-text-italic',
2323             underline: 'editor-text-underline',
2324           },
2325         },
2326       });
2327
2328       newEditor.setRootElement(container);
2329
2330       await newEditor.update(() => {
2331         const root = $getRoot();
2332         const paragraph = $createParagraphNode();
2333         const text = $createTextNode('123');
2334         root.append(paragraph);
2335         paragraph.append(text);
2336         expect(text instanceof TestTextNode).toBe(true);
2337         expect(text.getTextContent()).toBe('123');
2338       });
2339
2340       expect(onError).not.toHaveBeenCalled();
2341     });
2342
2343     it('should fail if node keys are re-used', async () => {
2344       const onError = jest.fn();
2345
2346       const newEditor = createTestEditor({
2347         nodes: [
2348           TestTextNode,
2349           {
2350             replace: TextNode,
2351             with: (node: TextNode) =>
2352               new TestTextNode(node.getTextContent(), node.getKey()),
2353           },
2354         ],
2355         onError: onError,
2356         theme: {
2357           text: {
2358             bold: 'editor-text-bold',
2359             italic: 'editor-text-italic',
2360             underline: 'editor-text-underline',
2361           },
2362         },
2363       });
2364
2365       newEditor.setRootElement(container);
2366
2367       await newEditor.update(() => {
2368         // this will throw
2369         $createTextNode('123');
2370         expect(false).toBe('unreachable');
2371       });
2372
2373       newEditor.commitUpdates();
2374
2375       expect(onError).toHaveBeenCalledWith(
2376         expect.objectContaining({
2377           message: expect.stringMatching(/TestTextNode.*re-use key.*TextNode/),
2378         }),
2379       );
2380     });
2381
2382     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 () => {
2383       const onError = jest.fn();
2384
2385       const newEditor = createTestEditor({
2386         nodes: [
2387           TestTextNode,
2388           {
2389             replace: TextNode,
2390             with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2391           },
2392         ],
2393         onError: onError,
2394         theme: {
2395           text: {
2396             bold: 'editor-text-bold',
2397             italic: 'editor-text-italic',
2398             underline: 'editor-text-underline',
2399           },
2400         },
2401       });
2402
2403       newEditor.setRootElement(container);
2404
2405       const mockTransform = jest.fn();
2406       const removeTransform = newEditor.registerNodeTransform(
2407         TextNode,
2408         mockTransform,
2409       );
2410
2411       await newEditor.update(() => {
2412         const root = $getRoot();
2413         const paragraph = $createParagraphNode();
2414         const text = $createTextNode('123');
2415         root.append(paragraph);
2416         paragraph.append(text);
2417         expect(text instanceof TestTextNode).toBe(true);
2418         expect(text.getTextContent()).toBe('123');
2419       });
2420
2421       await newEditor.getEditorState().read(() => {
2422         expect(mockTransform).toHaveBeenCalledTimes(0);
2423       });
2424
2425       expect(onError).not.toHaveBeenCalled();
2426       removeTransform();
2427     });
2428
2429     it('node transform to the nodes specified by "replace" should be applied also to the nodes specified by "with" when "withKlass" is specified', async () => {
2430       const onError = jest.fn();
2431
2432       const newEditor = createTestEditor({
2433         nodes: [
2434           TestTextNode,
2435           {
2436             replace: TextNode,
2437             with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2438             withKlass: TestTextNode,
2439           },
2440         ],
2441         onError: onError,
2442         theme: {
2443           text: {
2444             bold: 'editor-text-bold',
2445             italic: 'editor-text-italic',
2446             underline: 'editor-text-underline',
2447           },
2448         },
2449       });
2450
2451       newEditor.setRootElement(container);
2452
2453       const mockTransform = jest.fn();
2454       const removeTransform = newEditor.registerNodeTransform(
2455         TextNode,
2456         mockTransform,
2457       );
2458
2459       await newEditor.update(() => {
2460         const root = $getRoot();
2461         const paragraph = $createParagraphNode();
2462         const text = $createTextNode('123');
2463         root.append(paragraph);
2464         paragraph.append(text);
2465         expect(text instanceof TestTextNode).toBe(true);
2466         expect(text.getTextContent()).toBe('123');
2467       });
2468
2469       await newEditor.getEditorState().read(() => {
2470         expect(mockTransform).toHaveBeenCalledTimes(1);
2471       });
2472
2473       expect(onError).not.toHaveBeenCalled();
2474       removeTransform();
2475     });
2476   });
2477
2478   it('recovers from reconciler failure and trigger proper prev editor state', async () => {
2479     const updateListener = jest.fn();
2480     const textListener = jest.fn();
2481     const onError = jest.fn();
2482     const updateError = new Error('Failed updateDOM');
2483
2484     init(onError);
2485
2486     editor.registerUpdateListener(updateListener);
2487     editor.registerTextContentListener(textListener);
2488
2489     await update(() => {
2490       $getRoot().append(
2491         $createParagraphNode().append($createTextNode('Hello')),
2492       );
2493     });
2494
2495     // Cause reconciler error in update dom, so that it attempts to fallback by
2496     // reseting editor and rerendering whole content
2497     jest.spyOn(ParagraphNode.prototype, 'updateDOM').mockImplementation(() => {
2498       throw updateError;
2499     });
2500
2501     const editorState = editor.getEditorState();
2502
2503     editor.registerUpdateListener(updateListener);
2504
2505     await update(() => {
2506       $getRoot().append(
2507         $createParagraphNode().append($createTextNode('world')),
2508       );
2509     });
2510
2511     expect(onError).toBeCalledWith(updateError);
2512     expect(textListener).toBeCalledWith('Hello\n\nworld');
2513     expect(updateListener.mock.lastCall[0].prevEditorState).toBe(editorState);
2514   });
2515
2516   it('should call importDOM methods only once', async () => {
2517     jest.spyOn(ParagraphNode, 'importDOM');
2518
2519     class CustomParagraphNode extends ParagraphNode {
2520       static getType() {
2521         return 'custom-paragraph';
2522       }
2523
2524       static clone(node: CustomParagraphNode) {
2525         return new CustomParagraphNode(node.__key);
2526       }
2527
2528       static importJSON() {
2529         return new CustomParagraphNode();
2530       }
2531
2532       exportJSON() {
2533         return {...super.exportJSON(), type: 'custom-paragraph'};
2534       }
2535     }
2536
2537     createTestEditor({nodes: [CustomParagraphNode]});
2538
2539     expect(ParagraphNode.importDOM).toHaveBeenCalledTimes(1);
2540   });
2541
2542   it('root element count is always positive', () => {
2543     const newEditor1 = createTestEditor();
2544     const newEditor2 = createTestEditor();
2545
2546     const container1 = document.createElement('div');
2547     const container2 = document.createElement('div');
2548
2549     newEditor1.setRootElement(container1);
2550     newEditor1.setRootElement(null);
2551
2552     newEditor1.setRootElement(container1);
2553     newEditor2.setRootElement(container2);
2554     newEditor1.setRootElement(null);
2555     newEditor2.setRootElement(null);
2556   });
2557
2558   describe('html config', () => {
2559     it('should override export output function', async () => {
2560       const onError = jest.fn();
2561
2562       const newEditor = createTestEditor({
2563         html: {
2564           export: new Map([
2565             [
2566               TextNode,
2567               (_, target) => {
2568                 invariant($isTextNode(target));
2569
2570                 return {
2571                   element: target.hasFormat('bold')
2572                     ? document.createElement('bor')
2573                     : document.createElement('foo'),
2574                 };
2575               },
2576             ],
2577           ]),
2578         },
2579         onError: onError,
2580       });
2581
2582       newEditor.setRootElement(container);
2583
2584       newEditor.update(() => {
2585         const root = $getRoot();
2586         const paragraph = $createParagraphNode();
2587         const text = $createTextNode();
2588         root.append(paragraph);
2589         paragraph.append(text);
2590
2591         const selection = $createNodeSelection();
2592         selection.add(text.getKey());
2593
2594         const htmlFoo = $generateHtmlFromNodes(newEditor, selection);
2595         expect(htmlFoo).toBe('<foo></foo>');
2596
2597         text.toggleFormat('bold');
2598
2599         const htmlBold = $generateHtmlFromNodes(newEditor, selection);
2600         expect(htmlBold).toBe('<bor></bor>');
2601       });
2602
2603       expect(onError).not.toHaveBeenCalled();
2604     });
2605
2606     it('should override import conversion function', async () => {
2607       const onError = jest.fn();
2608
2609       const newEditor = createTestEditor({
2610         html: {
2611           import: {
2612             figure: () => ({
2613               conversion: () => ({node: $createTextNode('yolo')}),
2614               priority: 4,
2615             }),
2616           },
2617         },
2618         onError: onError,
2619       });
2620
2621       newEditor.setRootElement(container);
2622
2623       newEditor.update(() => {
2624         const html = '<figure></figure>';
2625
2626         const parser = new DOMParser();
2627         const dom = parser.parseFromString(html, 'text/html');
2628         const node = $generateNodesFromDOM(newEditor, dom)[0];
2629
2630         expect(node).toEqual({
2631           __detail: 0,
2632           __format: 0,
2633           __key: node.getKey(),
2634           __mode: 0,
2635           __next: null,
2636           __parent: null,
2637           __prev: null,
2638           __style: '',
2639           __text: 'yolo',
2640           __type: 'text',
2641         });
2642       });
2643
2644       expect(onError).not.toHaveBeenCalled();
2645     });
2646   });
2647 });