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