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