From 8e0e3007e99e89cc3977996bc5613b6196148526 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 29 Nov 2024 17:37:25 -0800 Subject: [PATCH] [lexical][lexical-playground] Bug Fix: Allow setEditorState to work correctly inside of an update (#6876) --- .../__tests__/e2e/Autocomplete.spec.mjs | 128 ++++++++++++++++++ .../src/plugins/AutocompletePlugin/index.tsx | 62 ++++----- packages/lexical/src/LexicalEditor.ts | 22 ++- .../src/__tests__/unit/LexicalEditor.test.tsx | 3 +- 4 files changed, 179 insertions(+), 36 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs b/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs index 39d3ef16e50..2a7c3156111 100644 --- a/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs @@ -13,6 +13,7 @@ import { toggleItalic, toggleStrikethrough, toggleUnderline, + undo, } from '../keyboardShortcuts/index.mjs'; import { assertHTML, @@ -155,4 +156,131 @@ test.describe('Autocomplete', () => { `, ); }); + test('Undo does not cause an exception', async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText); + // Autocomplete has known issues in collab https://github.com/facebook/lexical/issues/6844 + test.skip(isCollab); + await focusEditor(page); + await toggleBold(page); + await toggleItalic(page); + await toggleUnderline(page); + await toggleStrikethrough(page); + await increaseFontSize(page); + + await page.keyboard.type('Test'); + await sleep(500); + + await assertHTML( + page, + html` +

+ + Test + + + imonials (TAB) + +

+ `, + html` +

+ + Test + + +

+ `, + ); + + await page.keyboard.press('Tab'); + + await assertHTML( + page, + html` +

+ + Test + + + imonials + +

+ `, + html` +

+ + Test + + +

+ `, + ); + + await undo(page); + + await assertHTML( + page, + html` +

+ + Test + + + imonials (TAB) + +

+ `, + html` +

+ + Test + + +

+ `, + ); + }); }); diff --git a/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx b/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx index fa7d5fe5690..6f427cdfe7d 100644 --- a/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx @@ -12,6 +12,7 @@ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {$isAtNodeEnd} from '@lexical/selection'; import {mergeRegister} from '@lexical/utils'; import { + $addUpdateTag, $createTextNode, $getNodeByKey, $getSelection, @@ -31,6 +32,8 @@ import { } from '../../nodes/AutocompleteNode'; import {addSwipeRightListener} from '../../utils/swipe'; +const HISTORY_MERGE = {tag: 'history-merge'}; + declare global { interface Navigator { userAgentData?: { @@ -130,34 +133,27 @@ export default function AutocompletePlugin(): JSX.Element | null { // Outdated or no suggestion return; } - editor.update( - () => { - const selection = $getSelection(); - const [hasMatch, match] = $search(selection); - if ( - !hasMatch || - match !== lastMatch || - !$isRangeSelection(selection) - ) { - // Outdated - return; - } - const selectionCopy = selection.clone(); - const prevNode = selection.getNodes()[0] as TextNode; - prevNodeFormat = prevNode.getFormat(); - const node = $createAutocompleteNode( - formatSuggestionText(newSuggestion), - uuid, - ) - .setFormat(prevNodeFormat) - .setStyle(`font-size: ${toolbarState.fontSize}`); - autocompleteNodeKey = node.getKey(); - selection.insertNodes([node]); - $setSelection(selectionCopy); - lastSuggestion = newSuggestion; - }, - {tag: 'history-merge'}, - ); + editor.update(() => { + const selection = $getSelection(); + const [hasMatch, match] = $search(selection); + if (!hasMatch || match !== lastMatch || !$isRangeSelection(selection)) { + // Outdated + return; + } + const selectionCopy = selection.clone(); + const prevNode = selection.getNodes()[0] as TextNode; + prevNodeFormat = prevNode.getFormat(); + const node = $createAutocompleteNode( + formatSuggestionText(newSuggestion), + uuid, + ) + .setFormat(prevNodeFormat) + .setStyle(`font-size: ${toolbarState.fontSize}`); + autocompleteNodeKey = node.getKey(); + selection.insertNodes([node]); + $setSelection(selectionCopy); + lastSuggestion = newSuggestion; + }, HISTORY_MERGE); } function $handleAutocompleteNodeTransform(node: AutocompleteNode) { @@ -187,10 +183,12 @@ export default function AutocompletePlugin(): JSX.Element | null { } }) .catch((e) => { - console.error(e); + if (e !== 'Dismissed') { + console.error(e); + } }); lastMatch = match; - }); + }, HISTORY_MERGE); } function $handleAutocompleteIntent(): boolean { if (lastSuggestion === null || autocompleteNodeKey === null) { @@ -219,13 +217,15 @@ export default function AutocompletePlugin(): JSX.Element | null { editor.update(() => { if ($handleAutocompleteIntent()) { e.preventDefault(); + } else { + $addUpdateTag(HISTORY_MERGE.tag); } }); } function unmountSuggestion() { editor.update(() => { $clearSuggestion(); - }); + }, HISTORY_MERGE); } const rootElem = editor.getRootElement(); diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 157d8d04f95..02be308ba84 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -19,7 +19,7 @@ import invariant from 'shared/invariant'; import {$getRoot, $getSelection, TextNode} from '.'; import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants'; -import {createEmptyEditorState} from './LexicalEditorState'; +import {cloneEditorState, createEmptyEditorState} from './LexicalEditorState'; import {addRootElementEvents, removeRootElementEvents} from './LexicalEvents'; import {$flushRootMutations, initMutationObserver} from './LexicalMutations'; import {LexicalNode} from './LexicalNode'; @@ -1112,6 +1112,16 @@ export class LexicalEditor { ); } + // Ensure that we have a writable EditorState so that transforms can run + // during a historic operation + let writableEditorState = editorState; + if (writableEditorState._readOnly) { + writableEditorState = cloneEditorState(editorState); + writableEditorState._selection = editorState._selection + ? editorState._selection.clone() + : null; + } + $flushRootMutations(this); const pendingEditorState = this._pendingEditorState; const tags = this._updateTags; @@ -1121,11 +1131,10 @@ export class LexicalEditor { if (tag != null) { tags.add(tag); } - $commitPendingUpdates(this); } - this._pendingEditorState = editorState; + this._pendingEditorState = writableEditorState; this._dirtyType = FULL_RECONCILE; this._dirtyElements.set('root', false); this._compositionKey = null; @@ -1134,7 +1143,12 @@ export class LexicalEditor { tags.add(tag); } - $commitPendingUpdates(this); + // Only commit pending updates if not already in an editor.update + // (e.g. dispatchCommand) otherwise this will cause a second commit + // with an already read-only state and selection + if (!this._updating) { + $commitPendingUpdates(this); + } } /** diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 4b89e56c232..cf33a568d3f 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -2549,7 +2549,8 @@ describe('LexicalEditor tests', () => { `{"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}}`, ); editor.setEditorState(state); - expect(editor._editorState).toBe(state); + // A writable version of the EditorState may have been created, we settle for equal serializations + expect(editor._editorState.toJSON()).toEqual(state.toJSON()); expect(editor._pendingEditorState).toBe(null); });