2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
16 } from '@lexical/table';
21 $createRangeSelection,
24 $getNearestNodeFromDOMNode,
32 COMMAND_PRIORITY_EDITOR,
41 type LexicalNodeReplacement,
47 import invariant from 'lexical/shared/invariant';
50 $createTestDecoratorNode,
51 $createTestElementNode,
52 $createTestInlineElementNode,
54 createTestHeadlessEditor,
58 describe('LexicalEditor tests', () => {
59 let container: HTMLElement;
63 container = document.createElement('div');
64 reactRoot = createRoot(container);
65 document.body.appendChild(container);
69 document.body.removeChild(container);
73 jest.restoreAllMocks();
76 function useLexicalEditor(
77 rootElementRef: React.RefObject<HTMLDivElement>,
78 onError?: (error: Error) => void,
79 nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>,
81 const editor = useMemo(
85 onError: onError || jest.fn(),
88 bold: 'editor-text-bold',
89 italic: 'editor-text-italic',
90 underline: 'editor-text-underline',
98 const rootElement = rootElementRef.current;
100 editor.setRootElement(rootElement);
101 }, [rootElementRef, editor]);
106 let editor: LexicalEditor;
108 function init(onError?: (error: Error) => void) {
109 const ref = createRef<HTMLDivElement>();
111 function TestBase() {
112 editor = useLexicalEditor(ref, onError);
114 return <div ref={ref} contentEditable={true} />;
117 ReactTestUtils.act(() => {
118 reactRoot.render(<TestBase />);
122 async function update(fn: () => void) {
125 return Promise.resolve().then();
128 describe('read()', () => {
129 it('Can read the editor state', async () => {
130 init(function onError(err) {
133 expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
134 expect(editor.read(() => $getEditor())).toBe(editor);
135 const onUpdate = jest.fn();
138 const root = $getRoot();
139 const paragraph = $createParagraphNode();
140 const text = $createTextNode('This works!');
141 root.append(paragraph);
142 paragraph.append(text);
146 expect(onUpdate).toHaveBeenCalledTimes(0);
147 // This read will flush pending updates
148 expect(editor.read(() => $getRoot().getTextContent())).toEqual(
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);
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();
163 $isParagraphNode($getNearestNodeFromDOMNode(paragraphDom!)),
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(
175 $getNearestNodeFromDOMNode(textDom!.firstChild!)!.getTextContent(),
176 ).toBe('This works!');
178 expect(onUpdate).toHaveBeenCalledTimes(1);
180 it('runs transforms the editor state', async () => {
181 init(function onError(err) {
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!'));
191 const onUpdate = jest.fn();
194 const root = $getRoot();
195 const paragraph = $createParagraphNode();
196 const text = $createTextNode('This works!');
197 root.append(paragraph);
198 paragraph.append(text);
202 expect(onUpdate).toHaveBeenCalledTimes(0);
203 // This read will flush pending updates
204 expect(editor.read(() => $getRoot().getTextContent())).toEqual(
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(
216 it('can be nested in an update or read', async () => {
217 init(function onError(err) {
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);
227 expect($getRoot().getTextContent()).toBe('This works!');
230 // Nesting update in read works, although it is discouraged in the documentation.
231 editor.update(() => {
232 expect($getRoot().getTextContent()).toBe('This works!');
235 // Updating after a nested read will fail as it has already been committed
238 $createParagraphNode().append(
239 $createTextNode('update-read-update'),
246 expect($getRoot().getTextContent()).toBe('This works!');
252 it('Should create an editor with an initial editor state', async () => {
253 const rootElement = document.createElement('div');
255 container.appendChild(rootElement);
257 const initialEditor = createTestEditor({
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);
269 initialEditor.setRootElement(rootElement);
271 // Wait for update to complete
272 await Promise.resolve().then();
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>',
278 const initialEditorState = initialEditor.getEditorState();
279 initialEditor.setRootElement(null);
281 expect(container.innerHTML).toBe(
282 '<div style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"></div>',
285 editor = createTestEditor({
286 editorState: initialEditorState,
289 editor.setRootElement(rootElement);
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>',
297 it('Should handle nested updates in the correct sequence', async () => {
299 const onUpdate = jest.fn();
301 let log: Array<string> = [];
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);
315 // To enforce the update
316 $getRoot().markDirty();
345 // Wait for update to complete
346 await Promise.resolve().then();
348 expect(onUpdate).toHaveBeenCalledTimes(1);
349 expect(log).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1']);
355 // To enforce the update
356 $getRoot().markDirty();
364 $setCompositionKey('root');
388 // Wait for update to complete
389 await Promise.resolve().then();
391 expect(log).toEqual(['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2']);
394 editor.registerNodeTransform(TextNode, () => {
395 log.push('TextTransform A3');
398 log.push('TextTransform B3');
402 log.push('TextTransform C3');
408 // Wait for update to complete
409 await Promise.resolve().then();
411 expect(log).toEqual([
421 $getRoot().getLastDescendant()!.markDirty();
430 // Wait for update to complete
431 await Promise.resolve().then();
433 expect(log).toEqual([
442 it('nested update after selection update triggers exactly 1 update', async () => {
444 const onUpdate = jest.fn();
445 editor.registerUpdateListener(onUpdate);
446 editor.update(() => {
447 $setSelection($createRangeSelection());
448 editor.update(() => {
450 $createParagraphNode().append($createTextNode('Sync update')),
455 await Promise.resolve().then();
457 const textContent = editor
459 .read(() => $getRoot().getTextContent());
460 expect(textContent).toBe('Sync update');
461 expect(onUpdate).toHaveBeenCalledTimes(1);
464 it('update does not call onUpdate callback when no dirty nodes', () => {
467 const fn = jest.fn();
476 expect(fn).toHaveBeenCalledTimes(0);
479 it('editor.focus() callback is called', async () => {
482 await editor.update(() => {
483 const root = $getRoot();
484 root.append($createParagraphNode());
487 const fn = jest.fn();
489 await editor.focus(fn);
491 expect(fn).toHaveBeenCalledTimes(1);
494 it('Synchronously runs three transforms, two of them depend on the other', async () => {
498 const italicsListener = editor.registerNodeTransform(TextNode, (node) => {
500 node.getTextContent() === 'foo' &&
501 node.hasFormat('bold') &&
502 !node.hasFormat('italic')
504 node.toggleFormat('italic');
509 const boldListener = editor.registerNodeTransform(TextNode, (node) => {
510 if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) {
511 node.toggleFormat('bold');
516 const underlineListener = editor.registerNodeTransform(TextNode, (node) => {
518 node.getTextContent() === 'foo' &&
519 node.hasFormat('bold') &&
520 !node.hasFormat('underline')
522 node.toggleFormat('underline');
526 await editor.update(() => {
527 const root = $getRoot();
528 const paragraph = $createParagraphNode();
529 root.append(paragraph);
530 paragraph.append($createTextNode('foo'));
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>',
541 it('Synchronously runs three transforms, two of them depend on the other (2)', async () => {
544 // Add transform makes everything dirty the first time (let's not leverage this here)
545 const skipFirst = [true, true, true];
547 // 2. (Block transform) Add text
548 const testParagraphListener = editor.registerNodeTransform(
552 skipFirst[0] = false;
557 if (paragraph.isEmpty()) {
558 paragraph.append($createTextNode('foo'));
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');
570 // 3. (Block transform) Add italics to bold text
571 const italicsListener = editor.registerNodeTransform(
574 const child = paragraph.getLastDescendant();
577 $isTextNode(child) &&
578 child.hasFormat('bold') &&
579 !child.hasFormat('italic')
581 child.toggleFormat('italic');
586 await editor.update(() => {
587 const root = $getRoot();
588 const paragraph = $createParagraphNode();
589 root.append(paragraph);
592 await editor.update(() => {
593 const root = $getRoot();
594 const paragraph = root.getFirstChild();
595 paragraph!.markDirty();
598 testParagraphListener();
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>',
607 it('Synchronously runs three transforms, two of them depend on previously merged text content', async () => {
608 const hasRun = [false, false, false];
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);
616 before.insertBefore($createTextNode(''));
617 after.insertAfter($createTextNode(''));
618 after.insertAfter($createTextNode('!'));
619 after.insertAfter($createTextNode(''));
625 // 2. [Foo!] into [<empty>,Fo,o!,<empty>,!,<empty>]
626 const megaFooListener = editor.registerNodeTransform(
629 const child = paragraph.getFirstChild();
632 $isTextNode(child) &&
633 child.getTextContent() === 'Foo!' &&
636 const [before, after] = child.splitText(2);
638 before.insertBefore($createTextNode(''));
639 after.insertAfter($createTextNode(''));
640 after.insertAfter($createTextNode('!'));
641 after.insertAfter($createTextNode(''));
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');
653 const [before, after] = node.splitText(2);
654 before.insertBefore($createTextNode(''));
655 after.insertAfter($createTextNode(''));
661 await editor.update(() => {
662 const root = $getRoot();
663 const paragraph = $createParagraphNode();
665 root.append(paragraph);
666 paragraph.append($createTextNode('Foo'));
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>',
678 it('text transform runs when node is removed', async () => {
681 const executeTransform = jest.fn();
682 let hasBeenRemoved = false;
683 const removeListener = editor.registerNodeTransform(TextNode, (node) => {
684 if (hasBeenRemoved) {
689 await editor.update(() => {
690 const root = $getRoot();
691 const paragraph = $createParagraphNode();
692 root.append(paragraph);
694 $createTextNode('Foo').toggleUnmergeable(),
695 $createTextNode('Bar').toggleUnmergeable(),
699 await editor.update(() => {
700 $getRoot().getLastDescendant()!.remove();
701 hasBeenRemoved = true;
704 expect(executeTransform).toHaveBeenCalledTimes(1);
709 it('transforms only run on nodes that were explicitly marked as dirty', async () => {
712 let executeParagraphNodeTransform = () => {
716 let executeTextNodeTransform = () => {
720 const removeParagraphTransform = editor.registerNodeTransform(
723 executeParagraphNodeTransform();
726 const removeTextNodeTransform = editor.registerNodeTransform(
729 executeTextNodeTransform();
733 await editor.update(() => {
734 const root = $getRoot();
735 const paragraph = $createParagraphNode();
736 root.append(paragraph);
737 paragraph.append($createTextNode('Foo'));
740 await editor.update(() => {
741 const root = $getRoot();
742 const paragraph = root.getFirstChild() as ParagraphNode;
743 const textNode = paragraph.getFirstChild() as TextNode;
745 textNode.getWritable();
747 executeParagraphNodeTransform = jest.fn();
748 executeTextNodeTransform = jest.fn();
751 expect(executeParagraphNodeTransform).toHaveBeenCalledTimes(0);
752 expect(executeTextNodeTransform).toHaveBeenCalledTimes(1);
754 removeParagraphTransform();
755 removeTextNodeTransform();
758 describe('transforms on siblings', () => {
759 let textNodeKeys: string[];
760 let textTransformCount: number[];
761 let removeTransform: () => void;
763 beforeEach(async () => {
767 textTransformCount = [];
769 await editor.update(() => {
770 const root = $getRoot();
771 const paragraph0 = $createParagraphNode();
772 const paragraph1 = $createParagraphNode();
773 const textNodes: Array<LexicalNode> = [];
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;
782 root.append(paragraph0, paragraph1);
783 paragraph0.append(...textNodes.slice(0, 3));
784 paragraph1.append(...textNodes.slice(3));
787 removeTransform = editor.registerNodeTransform(TextNode, (node) => {
788 textTransformCount[Number(node.__text)]++;
796 it('on remove', async () => {
797 await editor.update(() => {
798 const textNode1 = $getNodeByKey(textNodeKeys[1])!;
801 expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1]);
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);
810 expect(textTransformCount).toEqual([2, 2, 2, 2, 1, 2]);
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);
819 expect(textTransformCount).toEqual([2, 2, 2, 2, 2, 1]);
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);
828 expect(textTransformCount).toEqual([2, 2, 2, 1, 2, 2]);
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);
838 expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1, 1, 1]);
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);
847 expect(textTransformCount).toEqual([1, 1, 2, 1, 1, 1, 1]);
851 it('Detects infinite recursivity on transforms', async () => {
852 const errorListener = jest.fn();
855 const boldListener = editor.registerNodeTransform(TextNode, (node) => {
856 node.toggleFormat('bold');
859 expect(errorListener).toHaveBeenCalledTimes(0);
861 await editor.update(() => {
862 const root = $getRoot();
863 const paragraph = $createParagraphNode();
864 root.append(paragraph);
865 paragraph.append($createTextNode('foo'));
868 expect(errorListener).toHaveBeenCalledTimes(1);
872 it('Should be able to update an editor state without a root element', () => {
873 const ref = createRef<HTMLDivElement>();
875 function TestBase({element}: {element: HTMLElement | null}) {
876 editor = useMemo(() => createTestEditor(), []);
879 editor.setRootElement(element);
882 return <div ref={ref} contentEditable={true} />;
885 ReactTestUtils.act(() => {
886 reactRoot.render(<TestBase element={null} />);
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);
896 expect(container.innerHTML).toBe('<div contenteditable="true"></div>');
898 ReactTestUtils.act(() => {
899 reactRoot.render(<TestBase element={ref.current} />);
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>',
907 it('Should be able to recover from an update error', async () => {
908 const errorListener = jest.fn();
910 editor.update(() => {
911 const root = $getRoot();
913 if (root.getFirstChild() === null) {
914 const paragraph = $createParagraphNode();
915 const text = $createTextNode('This works!');
916 root.append(paragraph);
917 paragraph.append(text);
921 // Wait for update to complete
922 await Promise.resolve().then();
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>',
927 expect(errorListener).toHaveBeenCalledTimes(0);
929 editor.update(() => {
930 const root = $getRoot();
932 .getFirstChild<ElementNode>()!
933 .getFirstChild<ElementNode>()!
934 .getFirstChild<TextNode>()!
935 .setTextContent('Foo');
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>',
944 it('Should be able to handle a change in root element', async () => {
945 const rootListener = jest.fn();
946 const updateListener = jest.fn();
948 function TestBase({changeElement}: {changeElement: boolean}) {
949 editor = useMemo(() => createTestEditor(), []);
952 editor.update(() => {
953 const root = $getRoot();
954 const firstChild = root.getFirstChild() as ParagraphNode | null;
955 const text = changeElement ? 'Change successful' : 'Not changed';
957 if (firstChild === null) {
958 const paragraph = $createParagraphNode();
959 const textNode = $createTextNode(text);
960 paragraph.append(textNode);
961 root.append(paragraph);
963 const textNode = firstChild.getFirstChild() as TextNode;
964 textNode.setTextContent(text);
970 return editor.registerRootListener(rootListener);
974 return editor.registerUpdateListener(updateListener);
977 const ref = useCallback((node: HTMLElement | null) => {
978 editor.setRootElement(node);
981 return changeElement ? (
982 <span ref={ref} contentEditable={true} />
984 <div ref={ref} contentEditable={true} />
988 await ReactTestUtils.act(() => {
989 reactRoot.render(<TestBase changeElement={false} />);
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>',
996 await ReactTestUtils.act(() => {
997 reactRoot.render(<TestBase changeElement={true} />);
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>',
1007 for (const editable of [true, false]) {
1008 it(`Retains pendingEditor while rootNode is not set (${
1009 editable ? 'editable' : 'non-editable'
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}}';
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(() => {
1022 editor.setRootElement(contentEditable);
1023 expect(JSON.stringify(editor.getEditorState().toJSON())).toBe(
1029 describe('With node decorators', () => {
1030 function useDecorators() {
1031 const [decorators, setDecorators] = useState(() =>
1032 editor.getDecorators<ReactNode>(),
1035 // Subscribe to changes
1037 return editor.registerDecoratorListener<ReactNode>((nextDecorators) => {
1038 setDecorators(nextDecorators);
1042 const decoratedPortals = useMemo(
1044 Object.keys(decorators).map((nodeKey) => {
1045 const reactDecorator = decorators[nodeKey];
1046 const element = editor.getElementByKey(nodeKey)!;
1048 return createPortal(reactDecorator, element);
1053 return decoratedPortals;
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();
1064 it('Should correctly render React component into Lexical node #1', async () => {
1065 const listener = jest.fn();
1068 editor = useMemo(() => createTestEditor(), []);
1071 editor.registerRootListener(listener);
1074 const ref = useCallback((node: HTMLDivElement | null) => {
1075 editor.setRootElement(node);
1078 const decorators = useDecorators();
1082 <div ref={ref} contentEditable={true} />
1088 ReactTestUtils.act(() => {
1089 reactRoot.render(<Test />);
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);
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>',
1108 it('Should correctly render React component into Lexical node #2', async () => {
1109 const listener = jest.fn();
1111 function Test({divKey}: {divKey: number}): JSX.Element {
1112 function TestPlugin() {
1113 [editor] = useLexicalComposerContext();
1116 return editor.registerRootListener(listener);
1127 // eslint-disable-next-line jsx-a11y/aria-role
1128 <ContentEditable key={divKey} role={null} spellCheck={null} />
1131 ErrorBoundary={LexicalErrorBoundary}
1138 await ReactTestUtils.act(async () => {
1139 reactRoot.render(<Test divKey={0} />);
1140 // Wait for update to complete
1141 await Promise.resolve().then();
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>',
1149 await ReactTestUtils.act(async () => {
1150 reactRoot.render(<Test divKey={1} />);
1151 // Wait for update to complete
1152 await Promise.resolve().then();
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>',
1160 // Wait for update to complete
1161 await Promise.resolve().then();
1163 editor.getEditorState().read(() => {
1164 const root = $getRoot();
1165 const paragraph = root.getFirstChild()!;
1166 expect(root).toEqual({
1169 __first: paragraph.getKey(),
1173 __last: paragraph.getKey(),
1181 expect(paragraph).toEqual({
1186 __key: paragraph.getKey(),
1195 __type: 'paragraph',
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;
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);
1218 const stringifiedEditorState = JSON.stringify(editor.getEditorState());
1219 const parsedEditorStateFromObject = editor.parseEditorState(
1220 JSON.parse(stringifiedEditorState),
1222 parsedEditorStateFromObject.read(() => {
1223 const root = $getRoot();
1224 expect(root.getTextContent()).toMatch(/Hello world/);
1228 describe('range selection', () => {
1229 beforeEach(async () => {
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);
1239 const stringifiedEditorState = JSON.stringify(
1240 editor.getEditorState().toJSON(),
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();
1252 it('Parses the nodes of a stringified editor state', async () => {
1253 expect(parsedRoot).toEqual({
1256 __first: paragraphKey,
1260 __last: paragraphKey,
1268 expect(parsedParagraph).toEqual({
1273 __key: paragraphKey,
1282 __type: 'paragraph',
1284 expect(parsedText).toEqual({
1290 __parent: paragraphKey,
1293 __text: 'Hello world',
1298 it('Parses the text content of the editor state', async () => {
1299 expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1302 expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1308 describe('node selection', () => {
1309 beforeEach(async () => {
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);
1321 const stringifiedEditorState = JSON.stringify(
1322 editor.getEditorState().toJSON(),
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();
1334 it('Parses the nodes of a stringified editor state', async () => {
1335 expect(parsedRoot).toEqual({
1338 __first: paragraphKey,
1342 __last: paragraphKey,
1350 expect(parsedParagraph).toEqual({
1355 __key: paragraphKey,
1364 __type: 'paragraph',
1366 expect(parsedText).toEqual({
1372 __parent: paragraphKey,
1375 __text: 'Hello world',
1380 it('Parses the text content of the editor state', async () => {
1381 expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(
1384 expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe(
1391 describe('$parseSerializedNode()', () => {
1392 it('parses serialized nodes', async () => {
1393 const expectedTextContent = 'Hello world\n\nHello world';
1394 let actualTextContent: string;
1396 await update(() => {
1399 const paragraph = $createParagraphNode();
1400 paragraph.append($createTextNode('Hello world'));
1401 root.append(paragraph);
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);
1409 root.append(...children);
1410 actualTextContent = root.getTextContent();
1412 expect(actualTextContent!).toEqual(expectedTextContent);
1416 describe('Node children', () => {
1417 beforeEach(async () => {
1423 async function reset() {
1426 await update(() => {
1427 const root = $getRoot();
1428 const paragraph = $createParagraphNode();
1429 root.append(paragraph);
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);
1439 return [elementNode, textNode];
1442 let paragraphNodeKey: string;
1443 let elementNode1Key: string;
1444 let textNode1Key: string;
1445 let elementNode2Key: string;
1446 let textNode2Key: string;
1448 await update(() => {
1449 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1450 paragraphNodeKey = paragraph.getKey();
1452 const [elementNode1, textNode1] = $createElementNodeWithText('A');
1453 elementNode1Key = elementNode1.getKey();
1454 textNode1Key = textNode1.getKey();
1456 const [elementNode2, textNode2] = $createElementNodeWithText('B');
1457 elementNode2Key = elementNode2.getKey();
1458 textNode2Key = textNode2.getKey();
1460 paragraph.append(elementNode1, elementNode2);
1463 await update(() => {
1464 const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode;
1465 const elementNode2 = $getNodeByKey(elementNode2Key) as TextNode;
1466 elementNode1.append(elementNode2);
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);
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>',
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);
1497 let elementNode1Key: string;
1498 let elementNode2Key: string;
1500 await update(() => {
1501 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1503 const elementNode1 = $createElementNodeWithText('A');
1504 elementNode1Key = elementNode1.getKey();
1506 const elementNode2 = $createElementNodeWithText('B');
1507 elementNode2Key = elementNode2.getKey();
1509 paragraph.append(elementNode1, elementNode2);
1512 await update(() => {
1513 const elementNode1 = $getNodeByKey(elementNode1Key) as TextNode;
1514 const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode;
1515 elementNode2.append(elementNode1);
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>',
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);
1532 let elementNode1Key: string;
1533 let elementNode2Key: string;
1534 let elementNode3Key: string;
1536 await update(() => {
1537 const paragraph = $getRoot().getFirstChild() as ParagraphNode;
1539 const elementNode1 = $createElementNodeWithText('A');
1540 elementNode1Key = elementNode1.getKey();
1542 const elementNode2 = $createElementNodeWithText('B');
1543 elementNode2Key = elementNode2.getKey();
1545 const elementNode3 = $createElementNodeWithText('C');
1546 elementNode3Key = elementNode3.getKey();
1548 paragraph.append(elementNode1, elementNode2, elementNode3);
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);
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>',
1565 it('can subscribe and unsubscribe from commands and the callback is fired', () => {
1568 const commandListener = jest.fn();
1569 const command = createCommand('TEST_COMMAND');
1570 const payload = 'testPayload';
1571 const removeCommandListener = editor.registerCommand(
1574 COMMAND_PRIORITY_EDITOR,
1576 editor.dispatchCommand(command, payload);
1577 editor.dispatchCommand(command, payload);
1578 editor.dispatchCommand(command, payload);
1580 expect(commandListener).toHaveBeenCalledTimes(3);
1581 expect(commandListener).toHaveBeenCalledWith(payload, editor);
1583 removeCommandListener();
1585 editor.dispatchCommand(command, payload);
1586 editor.dispatchCommand(command, payload);
1587 editor.dispatchCommand(command, payload);
1589 expect(commandListener).toHaveBeenCalledTimes(3);
1590 expect(commandListener).toHaveBeenCalledWith(payload, editor);
1593 it('removes the command from the command map when no listener are attached', () => {
1596 const commandListener = jest.fn();
1597 const commandListenerTwo = jest.fn();
1598 const command = createCommand('TEST_COMMAND');
1599 const removeCommandListener = editor.registerCommand(
1602 COMMAND_PRIORITY_EDITOR,
1604 const removeCommandListenerTwo = editor.registerCommand(
1607 COMMAND_PRIORITY_EDITOR,
1610 expect(editor._commands).toEqual(
1615 new Set([commandListener, commandListenerTwo]),
1625 removeCommandListener();
1627 expect(editor._commands).toEqual(
1632 new Set([commandListenerTwo]),
1642 removeCommandListenerTwo();
1644 expect(editor._commands).toEqual(new Map());
1647 it('can register transforms before updates', async () => {
1650 const emptyTransform = () => {
1654 const removeTextTransform = editor.registerNodeTransform(
1658 const removeParagraphTransform = editor.registerNodeTransform(
1663 await editor.update(() => {
1664 const root = $getRoot();
1665 const paragraph = $createParagraphNode();
1666 root.append(paragraph);
1669 removeTextTransform();
1670 removeParagraphTransform();
1673 it('textcontent listener', async () => {
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);
1684 editor.registerTextContentListener((text) => {
1688 await editor.update(() => {
1689 const root = $getRoot();
1690 const child = root.getLastDescendant()!;
1691 child.insertAfter($createTextNode('bar'));
1694 expect(fn).toHaveBeenCalledTimes(1);
1695 expect(fn).toHaveBeenCalledWith('foobar');
1697 await editor.update(() => {
1698 const root = $getRoot();
1699 const child = root.getLastDescendant()!;
1700 child.insertAfter($createLineBreakNode());
1703 expect(fn).toHaveBeenCalledTimes(2);
1704 expect(fn).toHaveBeenCalledWith('foobar\n');
1706 await editor.update(() => {
1707 const root = $getRoot();
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);
1717 expect(fn).toHaveBeenCalledTimes(3);
1718 expect(fn).toHaveBeenCalledWith('bar\n\nyar');
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);
1730 expect(fn).toHaveBeenCalledTimes(4);
1731 expect(fn).toHaveBeenCalledWith('bar\n\nyar\n\nbar2\n\nyar2');
1734 it('mutation listener', async () => {
1737 const paragraphNodeMutations = jest.fn();
1738 const textNodeMutations = jest.fn();
1739 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
1740 skipInitialization: false,
1742 editor.registerMutationListener(TextNode, textNodeMutations, {
1743 skipInitialization: false,
1745 const paragraphKeys: string[] = [];
1746 const textNodeKeys: string[] = [];
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());
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());
1769 await editor.update(() => {
1773 await editor.update(() => {
1774 const root = $getRoot();
1775 const paragraph = $createParagraphNode();
1777 paragraphKeys.push(paragraph.getKey());
1779 // Created and deleted in the same update (not attached to node)
1780 textNodeKeys.push($createTextNode('zzz').getKey());
1781 root.append(paragraph);
1784 expect(paragraphNodeMutations.mock.calls.length).toBe(3);
1785 expect(textNodeMutations.mock.calls.length).toBe(2);
1787 const [paragraphMutation1, paragraphMutation2, paragraphMutation3] =
1788 paragraphNodeMutations.mock.calls;
1789 const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
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');
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,
1812 expect(textNodeMutations.mock.calls.length).toBe(0);
1814 it('mutation listener with setEditorState', async () => {
1817 await editor.update(() => {
1818 $getRoot().append($createParagraphNode());
1821 const initialEditorState = editor.getEditorState();
1822 const textNodeMutations = jest.fn();
1823 editor.registerMutationListener(TextNode, textNodeMutations, {
1824 skipInitialization: false,
1826 const textNodeKeys: string[] = [];
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());
1835 const fooEditorState = editor.getEditorState();
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),
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());
1851 await editor.setEditorState(parsedFooEditorState);
1853 expect(textNodeMutations.mock.calls.length).toBe(4);
1860 ] = textNodeMutations.mock.calls;
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');
1874 it('mutation listener set for original node should work with the replaced node', async () => {
1875 const ref = createRef<HTMLDivElement>();
1877 function TestBase() {
1878 editor = useLexicalEditor(ref, undefined, [
1882 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1883 withKlass: TestTextNode,
1887 return <div ref={ref} contentEditable={true} />;
1890 ReactTestUtils.act(() => {
1891 reactRoot.render(<TestBase />);
1894 const textNodeMutations = jest.fn();
1895 const textNodeMutationsB = jest.fn();
1896 editor.registerMutationListener(TextNode, textNodeMutations, {
1897 skipInitialization: false,
1899 const textNodeKeys: string[] = [];
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());
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());
1921 editor.registerMutationListener(TextNode, textNodeMutationsB, {
1922 skipInitialization: false,
1925 await editor.update(() => {
1929 await editor.update(() => {
1930 const root = $getRoot();
1931 const paragraph = $createParagraphNode();
1933 // Created and deleted in the same update (not attached to node)
1934 textNodeKeys.push($createTextNode('zzz').getKey());
1935 root.append(paragraph);
1938 expect(textNodeMutations.mock.calls.length).toBe(2);
1939 expect(textNodeMutationsB.mock.calls.length).toBe(2);
1941 const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls;
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([]);
1954 const [textNodeMutationB1, textNodeMutationB2] =
1955 textNodeMutationsB.mock.calls;
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',
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([]);
1971 it('mutation listener should work with the replaced node', async () => {
1972 const ref = createRef<HTMLDivElement>();
1974 function TestBase() {
1975 editor = useLexicalEditor(ref, undefined, [
1979 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
1980 withKlass: TestTextNode,
1984 return <div ref={ref} contentEditable={true} />;
1987 ReactTestUtils.act(() => {
1988 reactRoot.render(<TestBase />);
1991 const textNodeMutations = jest.fn();
1992 const textNodeMutationsB = jest.fn();
1993 editor.registerMutationListener(TestTextNode, textNodeMutations, {
1994 skipInitialization: false,
1996 const textNodeKeys: string[] = [];
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());
2007 editor.registerMutationListener(TestTextNode, textNodeMutationsB, {
2008 skipInitialization: false,
2011 expect(textNodeMutations.mock.calls.length).toBe(1);
2013 const [textNodeMutation1] = textNodeMutations.mock.calls;
2015 expect(textNodeMutation1[0].size).toBe(1);
2016 expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created');
2017 expect([...textNodeMutation1[1].updateTags]).toEqual([]);
2019 const [textNodeMutationB1] = textNodeMutationsB.mock.calls;
2021 expect(textNodeMutationB1[0].size).toBe(1);
2022 expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created');
2023 expect([...textNodeMutationB1[1].updateTags]).toEqual([
2024 'registerMutationListener',
2028 it('mutation listeners does not trigger when other node types are mutated', async () => {
2031 const paragraphNodeMutations = jest.fn();
2032 const textNodeMutations = jest.fn();
2033 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
2034 skipInitialization: false,
2036 editor.registerMutationListener(TextNode, textNodeMutations, {
2037 skipInitialization: false,
2040 await editor.update(() => {
2041 $getRoot().append($createParagraphNode());
2044 expect(paragraphNodeMutations.mock.calls.length).toBe(1);
2045 expect(textNodeMutations.mock.calls.length).toBe(0);
2048 it('mutation listeners with normalization', async () => {
2051 const textNodeMutations = jest.fn();
2052 editor.registerMutationListener(TextNode, textNodeMutations, {
2053 skipInitialization: false,
2055 const textNodeKeys: string[] = [];
2057 await editor.update(() => {
2058 const root = $getRoot();
2059 const paragraph = $createParagraphNode();
2060 const textNode1 = $createTextNode('foo');
2061 const textNode2 = $createTextNode('bar');
2063 textNodeKeys.push(textNode1.getKey(), textNode2.getKey());
2064 root.append(paragraph);
2065 paragraph.append(textNode1, textNode2);
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());
2075 await editor.update(() => {
2076 const textNode3 = $getNodeByKey(textNodeKeys[2]) as TextNode;
2077 textNode3.toggleFormat('bold'); // Normalize with foobar
2080 expect(textNodeMutations.mock.calls.length).toBe(3);
2082 const [textNodeMutation1, textNodeMutation2, textNodeMutation3] =
2083 textNodeMutations.mock.calls;
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');
2094 it('mutation "update" listener', async () => {
2097 const paragraphNodeMutations = jest.fn();
2098 const textNodeMutations = jest.fn();
2100 editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
2101 skipInitialization: false,
2103 editor.registerMutationListener(TextNode, textNodeMutations, {
2104 skipInitialization: false,
2107 const paragraphNodeKeys: string[] = [];
2108 const textNodeKeys: string[] = [];
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);
2120 expect(paragraphNodeMutations.mock.calls.length).toBe(1);
2122 const [paragraphNodeMutation1] = paragraphNodeMutations.mock.calls;
2123 expect(textNodeMutations.mock.calls.length).toBe(1);
2125 const [textNodeMutation1] = textNodeMutations.mock.calls;
2127 expect(textNodeMutation1[0].size).toBe(1);
2128 expect(paragraphNodeMutation1[0].size).toBe(1);
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
2136 // Append text node to paragraph.
2137 await editor.update(() => {
2138 const paragraphNode1 = $getNodeByKey(
2139 paragraphNodeKeys[0],
2141 const textNode1 = $createTextNode('foo');
2142 paragraphNode1.append(textNode1);
2145 expect(textNodeMutations.mock.calls.length).toBe(3);
2147 const textNodeMutation2 = textNodeMutations.mock.calls[1];
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);
2153 const paragraphNodeMutation2 = paragraphNodeMutations.mock.calls[1];
2155 // Show ParagraphNode was updated when new text node was appended.
2156 expect(paragraphNodeMutation2[0].get(paragraphNodeKeys[0])).toBe('updated');
2158 let tableCellKey: string;
2159 let tableRowKey: string;
2161 const tableCellMutations = jest.fn();
2162 const tableRowMutations = jest.fn();
2164 editor.registerMutationListener(TableCellNode, tableCellMutations, {
2165 skipInitialization: false,
2167 editor.registerMutationListener(TableRowNode, tableRowMutations, {
2168 skipInitialization: false,
2172 await editor.update(() => {
2173 const root = $getRoot();
2174 const tableCell = $createTableCellNode(0);
2175 const tableRow = $createTableRowNode();
2176 const table = $createTableNode();
2178 tableRow.append(tableCell);
2179 table.append(tableRow);
2182 tableRowKey = tableRow.getKey();
2183 tableCellKey = tableCell.getKey();
2185 // Add New Table Cell To Row
2187 await editor.update(() => {
2188 const tableRow = $getNodeByKey(tableRowKey) as TableRowNode;
2189 const tableCell = $createTableCellNode(0);
2190 tableRow.append(tableCell);
2193 // Update Table Cell
2194 await editor.update(() => {
2195 const tableCell = $getNodeByKey(tableCellKey) as TableCellNode;
2196 tableCell.toggleHeaderStyle(1);
2199 expect(tableCellMutations.mock.calls.length).toBe(3);
2200 const tableCellMutation3 = tableCellMutations.mock.calls[2];
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);
2206 const tableRowMutation2 = tableRowMutations.mock.calls[1];
2208 // Show row is updated when a new child is added.
2209 expect(tableRowMutation2[0].get(tableRowKey!)).toBe('updated');
2212 it('editable listener', () => {
2215 const editableFn = jest.fn();
2216 editor.registerEditableListener(editableFn);
2218 expect(editor.isEditable()).toBe(true);
2220 editor.setEditable(false);
2222 expect(editor.isEditable()).toBe(false);
2224 editor.setEditable(true);
2226 expect(editableFn.mock.calls).toEqual([[false], [true]]);
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');
2240 editor.registerUpdateListener(() => {
2243 editor.registerUpdateListener(() => {
2248 editor.registerMutationListener(
2252 editor.registerMutationListener(
2257 {skipInitialization: true},
2260 {skipInitialization: false},
2263 editor.registerNodeTransform(ParagraphNode, () => {
2264 nodeTransformListener();
2265 editor.registerNodeTransform(ParagraphNode, () => {
2266 nodeTransformListener();
2270 editor.registerEditableListener(() => {
2272 editor.registerEditableListener(() => {
2277 editor.registerTextContentListener(() => {
2278 textContentListener();
2279 editor.registerTextContentListener(() => {
2280 textContentListener();
2284 editor.registerCommand(
2288 editor.registerCommand(
2291 COMMAND_PRIORITY_LOW,
2295 COMMAND_PRIORITY_LOW,
2298 await update(() => {
2300 $createParagraphNode().append($createTextNode('Hello world')),
2304 editor.dispatchCommand(TEST_COMMAND, false);
2306 editor.setEditable(false);
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);
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();
2323 editor.registerMutationListener(TextNode, mutationListenerA, {
2324 skipInitialization: false,
2326 expect(mutationListenerA).toHaveBeenCalledTimes(0);
2328 await update(() => {
2330 $createParagraphNode().append($createTextNode('Hello world')),
2334 function asymmetricMatcher<T>(asymmetricMatch: (x: T) => boolean) {
2335 return {asymmetricMatch};
2338 expect(mutationListenerA).toHaveBeenCalledTimes(1);
2339 expect(mutationListenerA).toHaveBeenLastCalledWith(
2341 expect.objectContaining({
2342 updateTags: asymmetricMatcher(
2343 (s: Set<string>) => !s.has('registerMutationListener'),
2347 editor.registerMutationListener(TextNode, mutationListenerB, {
2348 skipInitialization: false,
2350 editor.registerMutationListener(TextNode, mutationListenerC, {
2351 skipInitialization: true,
2353 expect(mutationListenerA).toHaveBeenCalledTimes(1);
2354 expect(mutationListenerB).toHaveBeenCalledTimes(1);
2355 expect(mutationListenerB).toHaveBeenLastCalledWith(
2357 expect.objectContaining({
2358 updateTags: asymmetricMatcher((s: Set<string>) =>
2359 s.has('registerMutationListener'),
2363 expect(mutationListenerC).toHaveBeenCalledTimes(0);
2364 await update(() => {
2366 $createParagraphNode().append($createTextNode('Another update!')),
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(
2375 expect.objectContaining({
2376 updateTags: asymmetricMatcher(
2377 (s: Set<string>) => !s.has('registerMutationListener'),
2384 it('can use discrete for synchronous updates', () => {
2386 const onUpdate = jest.fn();
2387 editor.registerUpdateListener(onUpdate);
2391 $createParagraphNode().append($createTextNode('Sync update')),
2399 const textContent = editor
2401 .read(() => $getRoot().getTextContent());
2402 expect(textContent).toBe('Sync update');
2403 expect(onUpdate).toHaveBeenCalledTimes(1);
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(() => {
2412 $createParagraphNode().append($createTextNode('Async update')),
2418 $createParagraphNode().append($createTextNode('Sync update')),
2426 const textContent = headless
2428 .read(() => $getRoot().getTextContent());
2429 expect(textContent).toBe('Async update\n\nSync update');
2430 expect(onUpdate).toHaveBeenCalledTimes(1);
2433 it('can use discrete after a non-discrete setEditorState to flush the entire queue', () => {
2438 $createParagraphNode().append($createTextNode('Async update')),
2446 const headless = createTestHeadlessEditor(editor.getEditorState());
2450 $createParagraphNode().append($createTextNode('Sync update')),
2457 const textContent = headless
2459 .read(() => $getRoot().getTextContent());
2460 expect(textContent).toBe('Async update\n\nSync update');
2463 it('can use discrete in a nested update to flush the entire queue', () => {
2465 const onUpdate = jest.fn();
2466 editor.registerUpdateListener(onUpdate);
2467 editor.update(() => {
2469 $createParagraphNode().append($createTextNode('Async update')),
2474 $createParagraphNode().append($createTextNode('Sync update')),
2483 const textContent = editor
2485 .read(() => $getRoot().getTextContent());
2486 expect(textContent).toBe('Async update\n\nSync update');
2487 expect(onUpdate).toHaveBeenCalledTimes(1);
2490 it('does not include linebreak into inline elements', async () => {
2493 await editor.update(() => {
2495 $createParagraphNode().append(
2496 $createTextNode('Hello'),
2497 $createTestInlineElementNode(),
2502 expect(container.firstElementChild?.innerHTML).toBe(
2503 '<p dir="ltr"><span data-lexical-text="true">Hello</span><a></a></p>',
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}}`,
2512 editor.setEditorState(state);
2513 expect(editor._editorState).toBe(state);
2514 expect(editor._pendingEditorState).toBe(null);
2517 describe('node replacement', () => {
2518 it('should work correctly', async () => {
2519 const onError = jest.fn();
2521 const newEditor = createTestEditor({
2526 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2532 bold: 'editor-text-bold',
2533 italic: 'editor-text-italic',
2534 underline: 'editor-text-underline',
2539 newEditor.setRootElement(container);
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');
2551 expect(onError).not.toHaveBeenCalled();
2554 it('should fail if node keys are re-used', async () => {
2555 const onError = jest.fn();
2557 const newEditor = createTestEditor({
2562 with: (node: TextNode) =>
2563 new TestTextNode(node.getTextContent(), node.getKey()),
2569 bold: 'editor-text-bold',
2570 italic: 'editor-text-italic',
2571 underline: 'editor-text-underline',
2576 newEditor.setRootElement(container);
2578 await newEditor.update(() => {
2580 $createTextNode('123');
2581 expect(false).toBe('unreachable');
2584 expect(onError).toHaveBeenCalledWith(
2585 expect.objectContaining({
2586 message: expect.stringMatching(/TestTextNode.*re-use key.*TextNode/),
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();
2594 const newEditor = createTestEditor({
2599 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2605 bold: 'editor-text-bold',
2606 italic: 'editor-text-italic',
2607 underline: 'editor-text-underline',
2612 newEditor.setRootElement(container);
2614 const mockTransform = jest.fn();
2615 const removeTransform = newEditor.registerNodeTransform(
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');
2630 await newEditor.getEditorState().read(() => {
2631 expect(mockTransform).toHaveBeenCalledTimes(0);
2634 expect(onError).not.toHaveBeenCalled();
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();
2641 const newEditor = createTestEditor({
2646 with: (node: TextNode) => new TestTextNode(node.getTextContent()),
2647 withKlass: TestTextNode,
2653 bold: 'editor-text-bold',
2654 italic: 'editor-text-italic',
2655 underline: 'editor-text-underline',
2660 newEditor.setRootElement(container);
2662 const mockTransform = jest.fn();
2663 const removeTransform = newEditor.registerNodeTransform(
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');
2678 await newEditor.getEditorState().read(() => {
2679 expect(mockTransform).toHaveBeenCalledTimes(1);
2682 expect(onError).not.toHaveBeenCalled();
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');
2695 editor.registerUpdateListener(updateListener);
2696 editor.registerTextContentListener(textListener);
2698 await update(() => {
2700 $createParagraphNode().append($createTextNode('Hello')),
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(() => {
2710 const editorState = editor.getEditorState();
2712 editor.registerUpdateListener(updateListener);
2714 await update(() => {
2716 $createParagraphNode().append($createTextNode('world')),
2720 expect(onError).toBeCalledWith(updateError);
2721 expect(textListener).toBeCalledWith('Hello\n\nworld');
2722 expect(updateListener.mock.lastCall[0].prevEditorState).toBe(editorState);
2725 it('should call importDOM methods only once', async () => {
2726 jest.spyOn(ParagraphNode, 'importDOM');
2728 class CustomParagraphNode extends ParagraphNode {
2730 return 'custom-paragraph';
2733 static clone(node: CustomParagraphNode) {
2734 return new CustomParagraphNode(node.__key);
2737 static importJSON() {
2738 return new CustomParagraphNode();
2742 return {...super.exportJSON(), type: 'custom-paragraph'};
2746 createTestEditor({nodes: [CustomParagraphNode]});
2748 expect(ParagraphNode.importDOM).toHaveBeenCalledTimes(1);
2751 it('root element count is always positive', () => {
2752 const newEditor1 = createTestEditor();
2753 const newEditor2 = createTestEditor();
2755 const container1 = document.createElement('div');
2756 const container2 = document.createElement('div');
2758 newEditor1.setRootElement(container1);
2759 newEditor1.setRootElement(null);
2761 newEditor1.setRootElement(container1);
2762 newEditor2.setRootElement(container2);
2763 newEditor1.setRootElement(null);
2764 newEditor2.setRootElement(null);
2767 describe('html config', () => {
2768 it('should override export output function', async () => {
2769 const onError = jest.fn();
2771 const newEditor = createTestEditor({
2777 invariant($isTextNode(target));
2780 element: target.hasFormat('bold')
2781 ? document.createElement('bor')
2782 : document.createElement('foo'),
2791 newEditor.setRootElement(container);
2793 newEditor.update(() => {
2794 const root = $getRoot();
2795 const paragraph = $createParagraphNode();
2796 const text = $createTextNode();
2797 root.append(paragraph);
2798 paragraph.append(text);
2800 const selection = $createNodeSelection();
2801 selection.add(text.getKey());
2803 const htmlFoo = $generateHtmlFromNodes(newEditor, selection);
2804 expect(htmlFoo).toBe('<foo></foo>');
2806 text.toggleFormat('bold');
2808 const htmlBold = $generateHtmlFromNodes(newEditor, selection);
2809 expect(htmlBold).toBe('<bor></bor>');
2812 expect(onError).not.toHaveBeenCalled();
2815 it('should override import conversion function', async () => {
2816 const onError = jest.fn();
2818 const newEditor = createTestEditor({
2822 conversion: () => ({node: $createTextNode('yolo')}),
2830 newEditor.setRootElement(container);
2832 newEditor.update(() => {
2833 const html = '<figure></figure>';
2835 const parser = new DOMParser();
2836 const dom = parser.parseFromString(html, 'text/html');
2837 const node = $generateNodesFromDOM(newEditor, dom)[0];
2839 expect(node).toEqual({
2842 __key: node.getKey(),
2853 expect(onError).not.toHaveBeenCalled();