/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import type {SerializedEditorState} from './LexicalEditorState'; import type {LexicalNode, SerializedLexicalNode} from './LexicalNode'; import invariant from 'lexical/shared/invariant'; import {$isElementNode, $isTextNode, SELECTION_CHANGE_COMMAND} from '.'; import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants'; import { CommandPayloadType, EditorUpdateOptions, LexicalCommand, LexicalEditor, Listener, MutatedNodes, RegisteredNodes, resetEditor, Transform, } from './LexicalEditor'; import { cloneEditorState, createEmptyEditorState, EditorState, editorStateHasDirtySelection, } from './LexicalEditorState'; import { $garbageCollectDetachedDecorators, $garbageCollectDetachedNodes, } from './LexicalGC'; import {initMutationObserver} from './LexicalMutations'; import {$normalizeTextNode} from './LexicalNormalization'; import {$reconcileRoot} from './LexicalReconciler'; import { $internalCreateSelection, $isNodeSelection, $isRangeSelection, applySelectionTransforms, updateDOMSelection, } from './LexicalSelection'; import { $getCompositionKey, getDOMSelection, getEditorPropertyFromDOMNode, getEditorStateTextContent, getEditorsToPropagate, getRegisteredNodeOrThrow, isLexicalEditor, removeDOMBlockCursorElement, scheduleMicroTask, updateDOMBlockCursorElement, } from './LexicalUtils'; let activeEditorState: null | EditorState = null; let activeEditor: null | LexicalEditor = null; let isReadOnlyMode = false; let isAttemptingToRecoverFromReconcilerError = false; let infiniteTransformCount = 0; const observerOptions = { characterData: true, childList: true, subtree: true, }; export function isCurrentlyReadOnlyMode(): boolean { return ( isReadOnlyMode || (activeEditorState !== null && activeEditorState._readOnly) ); } export function errorOnReadOnly(): void { if (isReadOnlyMode) { invariant(false, 'Cannot use method in read-only mode.'); } } export function errorOnInfiniteTransforms(): void { if (infiniteTransformCount > 99) { invariant( false, 'One or more transforms are endlessly triggering additional transforms. May have encountered infinite recursion caused by transforms that have their preconditions too lose and/or conflict with each other.', ); } } export function getActiveEditorState(): EditorState { if (activeEditorState === null) { invariant( false, 'Unable to find an active editor state. ' + 'State helpers or node methods can only be used ' + 'synchronously during the callback of ' + 'editor.update(), editor.read(), or editorState.read().%s', collectBuildInformation(), ); } return activeEditorState; } export function getActiveEditor(): LexicalEditor { if (activeEditor === null) { invariant( false, 'Unable to find an active editor. ' + 'This method can only be used ' + 'synchronously during the callback of ' + 'editor.update() or editor.read().%s', collectBuildInformation(), ); } return activeEditor; } function collectBuildInformation(): string { let compatibleEditors = 0; const incompatibleEditors = new Set(); const thisVersion = LexicalEditor.version; if (typeof window !== 'undefined') { for (const node of document.querySelectorAll('[contenteditable]')) { const editor = getEditorPropertyFromDOMNode(node); if (isLexicalEditor(editor)) { compatibleEditors++; } else if (editor) { let version = String( ( editor.constructor as typeof editor['constructor'] & Record ).version || '<0.17.1', ); if (version === thisVersion) { version += ' (separately built, likely a bundler configuration issue)'; } incompatibleEditors.add(version); } } } let output = ` Detected on the page: ${compatibleEditors} compatible editor(s) with version ${thisVersion}`; if (incompatibleEditors.size) { output += ` and incompatible editors with versions ${Array.from( incompatibleEditors, ).join(', ')}`; } return output; } export function internalGetActiveEditor(): LexicalEditor | null { return activeEditor; } export function internalGetActiveEditorState(): EditorState | null { return activeEditorState; } export function $applyTransforms( editor: LexicalEditor, node: LexicalNode, transformsCache: Map>>, ) { const type = node.__type; const registeredNode = getRegisteredNodeOrThrow(editor, type); let transformsArr = transformsCache.get(type); if (transformsArr === undefined) { transformsArr = Array.from(registeredNode.transforms); transformsCache.set(type, transformsArr); } const transformsArrLength = transformsArr.length; for (let i = 0; i < transformsArrLength; i++) { transformsArr[i](node); if (!node.isAttached()) { break; } } } function $isNodeValidForTransform( node: LexicalNode, compositionKey: null | string, ): boolean { return ( node !== undefined && // We don't want to transform nodes being composed node.__key !== compositionKey && node.isAttached() ); } function $normalizeAllDirtyTextNodes( editorState: EditorState, editor: LexicalEditor, ): void { const dirtyLeaves = editor._dirtyLeaves; const nodeMap = editorState._nodeMap; for (const nodeKey of dirtyLeaves) { const node = nodeMap.get(nodeKey); if ( $isTextNode(node) && node.isAttached() && node.isSimpleText() && !node.isUnmergeable() ) { $normalizeTextNode(node); } } } /** * Transform heuristic: * 1. We transform leaves first. If transforms generate additional dirty nodes we repeat step 1. * The reasoning behind this is that marking a leaf as dirty marks all its parent elements as dirty too. * 2. We transform elements. If element transforms generate additional dirty nodes we repeat step 1. * If element transforms only generate additional dirty elements we only repeat step 2. * * Note that to keep track of newly dirty nodes and subtrees we leverage the editor._dirtyNodes and * editor._subtrees which we reset in every loop. */ function $applyAllTransforms( editorState: EditorState, editor: LexicalEditor, ): void { const dirtyLeaves = editor._dirtyLeaves; const dirtyElements = editor._dirtyElements; const nodeMap = editorState._nodeMap; const compositionKey = $getCompositionKey(); const transformsCache = new Map(); let untransformedDirtyLeaves = dirtyLeaves; let untransformedDirtyLeavesLength = untransformedDirtyLeaves.size; let untransformedDirtyElements = dirtyElements; let untransformedDirtyElementsLength = untransformedDirtyElements.size; while ( untransformedDirtyLeavesLength > 0 || untransformedDirtyElementsLength > 0 ) { if (untransformedDirtyLeavesLength > 0) { // We leverage editor._dirtyLeaves to track the new dirty leaves after the transforms editor._dirtyLeaves = new Set(); for (const nodeKey of untransformedDirtyLeaves) { const node = nodeMap.get(nodeKey); if ( $isTextNode(node) && node.isAttached() && node.isSimpleText() && !node.isUnmergeable() ) { $normalizeTextNode(node); } if ( node !== undefined && $isNodeValidForTransform(node, compositionKey) ) { $applyTransforms(editor, node, transformsCache); } dirtyLeaves.add(nodeKey); } untransformedDirtyLeaves = editor._dirtyLeaves; untransformedDirtyLeavesLength = untransformedDirtyLeaves.size; // We want to prioritize node transforms over element transforms if (untransformedDirtyLeavesLength > 0) { infiniteTransformCount++; continue; } } // All dirty leaves have been processed. Let's do elements! // We have previously processed dirty leaves, so let's restart the editor leaves Set to track // new ones caused by element transforms editor._dirtyLeaves = new Set(); editor._dirtyElements = new Map(); for (const currentUntransformedDirtyElement of untransformedDirtyElements) { const nodeKey = currentUntransformedDirtyElement[0]; const intentionallyMarkedAsDirty = currentUntransformedDirtyElement[1]; if (nodeKey !== 'root' && !intentionallyMarkedAsDirty) { continue; } const node = nodeMap.get(nodeKey); if ( node !== undefined && $isNodeValidForTransform(node, compositionKey) ) { $applyTransforms(editor, node, transformsCache); } dirtyElements.set(nodeKey, intentionallyMarkedAsDirty); } untransformedDirtyLeaves = editor._dirtyLeaves; untransformedDirtyLeavesLength = untransformedDirtyLeaves.size; untransformedDirtyElements = editor._dirtyElements; untransformedDirtyElementsLength = untransformedDirtyElements.size; infiniteTransformCount++; } editor._dirtyLeaves = dirtyLeaves; editor._dirtyElements = dirtyElements; } type InternalSerializedNode = { children?: Array; type: string; version: number; }; export function $parseSerializedNode( serializedNode: SerializedLexicalNode, ): LexicalNode { const internalSerializedNode: InternalSerializedNode = serializedNode; return $parseSerializedNodeImpl( internalSerializedNode, getActiveEditor()._nodes, ); } function $parseSerializedNodeImpl< SerializedNode extends InternalSerializedNode, >( serializedNode: SerializedNode, registeredNodes: RegisteredNodes, ): LexicalNode { const type = serializedNode.type; const registeredNode = registeredNodes.get(type); if (registeredNode === undefined) { invariant(false, 'parseEditorState: type "%s" + not found', type); } const nodeClass = registeredNode.klass; if (serializedNode.type !== nodeClass.getType()) { invariant( false, 'LexicalNode: Node %s does not implement .importJSON().', nodeClass.name, ); } const node = nodeClass.importJSON(serializedNode); const children = serializedNode.children; if ($isElementNode(node) && Array.isArray(children)) { for (let i = 0; i < children.length; i++) { const serializedJSONChildNode = children[i]; const childNode = $parseSerializedNodeImpl( serializedJSONChildNode, registeredNodes, ); node.append(childNode); } } return node; } export function parseEditorState( serializedEditorState: SerializedEditorState, editor: LexicalEditor, updateFn: void | (() => void), ): EditorState { const editorState = createEmptyEditorState(); const previousActiveEditorState = activeEditorState; const previousReadOnlyMode = isReadOnlyMode; const previousActiveEditor = activeEditor; const previousDirtyElements = editor._dirtyElements; const previousDirtyLeaves = editor._dirtyLeaves; const previousCloneNotNeeded = editor._cloneNotNeeded; const previousDirtyType = editor._dirtyType; editor._dirtyElements = new Map(); editor._dirtyLeaves = new Set(); editor._cloneNotNeeded = new Set(); editor._dirtyType = 0; activeEditorState = editorState; isReadOnlyMode = false; activeEditor = editor; try { const registeredNodes = editor._nodes; const serializedNode = serializedEditorState.root; $parseSerializedNodeImpl(serializedNode, registeredNodes); if (updateFn) { updateFn(); } // Make the editorState immutable editorState._readOnly = true; if (__DEV__) { handleDEVOnlyPendingUpdateGuarantees(editorState); } } catch (error) { if (error instanceof Error) { editor._onError(error); } } finally { editor._dirtyElements = previousDirtyElements; editor._dirtyLeaves = previousDirtyLeaves; editor._cloneNotNeeded = previousCloneNotNeeded; editor._dirtyType = previousDirtyType; activeEditorState = previousActiveEditorState; isReadOnlyMode = previousReadOnlyMode; activeEditor = previousActiveEditor; } return editorState; } // This technically isn't an update but given we need // exposure to the module's active bindings, we have this // function here export function readEditorState( editor: LexicalEditor | null, editorState: EditorState, callbackFn: () => V, ): V { const previousActiveEditorState = activeEditorState; const previousReadOnlyMode = isReadOnlyMode; const previousActiveEditor = activeEditor; activeEditorState = editorState; isReadOnlyMode = true; activeEditor = editor; try { return callbackFn(); } finally { activeEditorState = previousActiveEditorState; isReadOnlyMode = previousReadOnlyMode; activeEditor = previousActiveEditor; } } function handleDEVOnlyPendingUpdateGuarantees( pendingEditorState: EditorState, ): void { // Given we can't Object.freeze the nodeMap as it's a Map, // we instead replace its set, clear and delete methods. const nodeMap = pendingEditorState._nodeMap; nodeMap.set = () => { throw new Error('Cannot call set() on a frozen Lexical node map'); }; nodeMap.clear = () => { throw new Error('Cannot call clear() on a frozen Lexical node map'); }; nodeMap.delete = () => { throw new Error('Cannot call delete() on a frozen Lexical node map'); }; } export function $commitPendingUpdates( editor: LexicalEditor, recoveryEditorState?: EditorState, ): void { const pendingEditorState = editor._pendingEditorState; const rootElement = editor._rootElement; const shouldSkipDOM = editor._headless || rootElement === null; if (pendingEditorState === null) { return; } // ====== // Reconciliation has started. // ====== const currentEditorState = editor._editorState; const currentSelection = currentEditorState._selection; const pendingSelection = pendingEditorState._selection; const needsUpdate = editor._dirtyType !== NO_DIRTY_NODES; const previousActiveEditorState = activeEditorState; const previousReadOnlyMode = isReadOnlyMode; const previousActiveEditor = activeEditor; const previouslyUpdating = editor._updating; const observer = editor._observer; let mutatedNodes = null; editor._pendingEditorState = null; editor._editorState = pendingEditorState; if (!shouldSkipDOM && needsUpdate && observer !== null) { activeEditor = editor; activeEditorState = pendingEditorState; isReadOnlyMode = false; // We don't want updates to sync block the reconciliation. editor._updating = true; try { const dirtyType = editor._dirtyType; const dirtyElements = editor._dirtyElements; const dirtyLeaves = editor._dirtyLeaves; observer.disconnect(); mutatedNodes = $reconcileRoot( currentEditorState, pendingEditorState, editor, dirtyType, dirtyElements, dirtyLeaves, ); } catch (error) { // Report errors if (error instanceof Error) { editor._onError(error); } // Reset editor and restore incoming editor state to the DOM if (!isAttemptingToRecoverFromReconcilerError) { resetEditor(editor, null, rootElement, pendingEditorState); initMutationObserver(editor); editor._dirtyType = FULL_RECONCILE; isAttemptingToRecoverFromReconcilerError = true; $commitPendingUpdates(editor, currentEditorState); isAttemptingToRecoverFromReconcilerError = false; } else { // To avoid a possible situation of infinite loops, lets throw throw error; } return; } finally { observer.observe(rootElement as Node, observerOptions); editor._updating = previouslyUpdating; activeEditorState = previousActiveEditorState; isReadOnlyMode = previousReadOnlyMode; activeEditor = previousActiveEditor; } } if (!pendingEditorState._readOnly) { pendingEditorState._readOnly = true; if (__DEV__) { handleDEVOnlyPendingUpdateGuarantees(pendingEditorState); if ($isRangeSelection(pendingSelection)) { Object.freeze(pendingSelection.anchor); Object.freeze(pendingSelection.focus); } Object.freeze(pendingSelection); } } const dirtyLeaves = editor._dirtyLeaves; const dirtyElements = editor._dirtyElements; const normalizedNodes = editor._normalizedNodes; const tags = editor._updateTags; const deferred = editor._deferred; const nodeCount = pendingEditorState._nodeMap.size; if (needsUpdate) { editor._dirtyType = NO_DIRTY_NODES; editor._cloneNotNeeded.clear(); editor._dirtyLeaves = new Set(); editor._dirtyElements = new Map(); editor._normalizedNodes = new Set(); editor._updateTags = new Set(); } $garbageCollectDetachedDecorators(editor, pendingEditorState); // ====== // Reconciliation has finished. Now update selection and trigger listeners. // ====== const domSelection = shouldSkipDOM ? null : getDOMSelection(editor._window); // Attempt to update the DOM selection, including focusing of the root element, // and scroll into view if needed. if ( editor._editable && // domSelection will be null in headless domSelection !== null && (needsUpdate || pendingSelection === null || pendingSelection.dirty) ) { activeEditor = editor; activeEditorState = pendingEditorState; try { if (observer !== null) { observer.disconnect(); } if (needsUpdate || pendingSelection === null || pendingSelection.dirty) { const blockCursorElement = editor._blockCursorElement; if (blockCursorElement !== null) { removeDOMBlockCursorElement( blockCursorElement, editor, rootElement as HTMLElement, ); } updateDOMSelection( currentSelection, pendingSelection, editor, domSelection, tags, rootElement as HTMLElement, nodeCount, ); } updateDOMBlockCursorElement( editor, rootElement as HTMLElement, pendingSelection, ); if (observer !== null) { observer.observe(rootElement as Node, observerOptions); } } finally { activeEditor = previousActiveEditor; activeEditorState = previousActiveEditorState; } } if (mutatedNodes !== null) { triggerMutationListeners( editor, mutatedNodes, tags, dirtyLeaves, currentEditorState, ); } if ( !$isRangeSelection(pendingSelection) && pendingSelection !== null && (currentSelection === null || !currentSelection.is(pendingSelection)) ) { editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); } /** * Capture pendingDecorators after garbage collecting detached decorators */ const pendingDecorators = editor._pendingDecorators; if (pendingDecorators !== null) { editor._decorators = pendingDecorators; editor._pendingDecorators = null; triggerListeners('decorator', editor, true, pendingDecorators); } // If reconciler fails, we reset whole editor (so current editor state becomes empty) // and attempt to re-render pendingEditorState. If that goes through we trigger // listeners, but instead use recoverEditorState which is current editor state before reset // This specifically important for collab that relies on prevEditorState from update // listener to calculate delta of changed nodes/properties triggerTextContentListeners( editor, recoveryEditorState || currentEditorState, pendingEditorState, ); triggerListeners('update', editor, true, { dirtyElements, dirtyLeaves, editorState: pendingEditorState, normalizedNodes, prevEditorState: recoveryEditorState || currentEditorState, tags, }); triggerDeferredUpdateCallbacks(editor, deferred); $triggerEnqueuedUpdates(editor); } function triggerTextContentListeners( editor: LexicalEditor, currentEditorState: EditorState, pendingEditorState: EditorState, ): void { const currentTextContent = getEditorStateTextContent(currentEditorState); const latestTextContent = getEditorStateTextContent(pendingEditorState); if (currentTextContent !== latestTextContent) { triggerListeners('textcontent', editor, true, latestTextContent); } } function triggerMutationListeners( editor: LexicalEditor, mutatedNodes: MutatedNodes, updateTags: Set, dirtyLeaves: Set, prevEditorState: EditorState, ): void { const listeners = Array.from(editor._listeners.mutation); const listenersLength = listeners.length; for (let i = 0; i < listenersLength; i++) { const [listener, klass] = listeners[i]; const mutatedNodesByType = mutatedNodes.get(klass); if (mutatedNodesByType !== undefined) { listener(mutatedNodesByType, { dirtyLeaves, prevEditorState, updateTags, }); } } } export function triggerListeners( type: 'update' | 'root' | 'decorator' | 'textcontent' | 'editable', editor: LexicalEditor, isCurrentlyEnqueuingUpdates: boolean, ...payload: unknown[] ): void { const previouslyUpdating = editor._updating; editor._updating = isCurrentlyEnqueuingUpdates; try { const listeners = Array.from(editor._listeners[type]); for (let i = 0; i < listeners.length; i++) { // @ts-ignore listeners[i].apply(null, payload); } } finally { editor._updating = previouslyUpdating; } } export function triggerCommandListeners< TCommand extends LexicalCommand, >( editor: LexicalEditor, type: TCommand, payload: CommandPayloadType, ): boolean { if (editor._updating === false || activeEditor !== editor) { let returnVal = false; editor.update(() => { returnVal = triggerCommandListeners(editor, type, payload); }); return returnVal; } const editors = getEditorsToPropagate(editor); for (let i = 4; i >= 0; i--) { for (let e = 0; e < editors.length; e++) { const currentEditor = editors[e]; const commandListeners = currentEditor._commands; const listenerInPriorityOrder = commandListeners.get(type); if (listenerInPriorityOrder !== undefined) { const listenersSet = listenerInPriorityOrder[i]; if (listenersSet !== undefined) { const listeners = Array.from(listenersSet); const listenersLength = listeners.length; for (let j = 0; j < listenersLength; j++) { if (listeners[j](payload, editor) === true) { return true; } } } } } } return false; } function $triggerEnqueuedUpdates(editor: LexicalEditor): void { const queuedUpdates = editor._updates; if (queuedUpdates.length !== 0) { const queuedUpdate = queuedUpdates.shift(); if (queuedUpdate) { const [updateFn, options] = queuedUpdate; $beginUpdate(editor, updateFn, options); } } } function triggerDeferredUpdateCallbacks( editor: LexicalEditor, deferred: Array<() => void>, ): void { editor._deferred = []; if (deferred.length !== 0) { const previouslyUpdating = editor._updating; editor._updating = true; try { for (let i = 0; i < deferred.length; i++) { deferred[i](); } } finally { editor._updating = previouslyUpdating; } } } function processNestedUpdates( editor: LexicalEditor, initialSkipTransforms?: boolean, ): boolean { const queuedUpdates = editor._updates; let skipTransforms = initialSkipTransforms || false; // Updates might grow as we process them, we so we'll need // to handle each update as we go until the updates array is // empty. while (queuedUpdates.length !== 0) { const queuedUpdate = queuedUpdates.shift(); if (queuedUpdate) { const [nextUpdateFn, options] = queuedUpdate; let onUpdate; let tag; if (options !== undefined) { onUpdate = options.onUpdate; tag = options.tag; if (options.skipTransforms) { skipTransforms = true; } if (options.discrete) { const pendingEditorState = editor._pendingEditorState; invariant( pendingEditorState !== null, 'Unexpected empty pending editor state on discrete nested update', ); pendingEditorState._flushSync = true; } if (onUpdate) { editor._deferred.push(onUpdate); } if (tag) { editor._updateTags.add(tag); } } nextUpdateFn(); } } return skipTransforms; } function $beginUpdate( editor: LexicalEditor, updateFn: () => void, options?: EditorUpdateOptions, ): void { const updateTags = editor._updateTags; let onUpdate; let tag; let skipTransforms = false; let discrete = false; if (options !== undefined) { onUpdate = options.onUpdate; tag = options.tag; if (tag != null) { updateTags.add(tag); } skipTransforms = options.skipTransforms || false; discrete = options.discrete || false; } if (onUpdate) { editor._deferred.push(onUpdate); } const currentEditorState = editor._editorState; let pendingEditorState = editor._pendingEditorState; let editorStateWasCloned = false; if (pendingEditorState === null || pendingEditorState._readOnly) { pendingEditorState = editor._pendingEditorState = cloneEditorState( pendingEditorState || currentEditorState, ); editorStateWasCloned = true; } pendingEditorState._flushSync = discrete; const previousActiveEditorState = activeEditorState; const previousReadOnlyMode = isReadOnlyMode; const previousActiveEditor = activeEditor; const previouslyUpdating = editor._updating; activeEditorState = pendingEditorState; isReadOnlyMode = false; editor._updating = true; activeEditor = editor; try { if (editorStateWasCloned) { if (editor._headless) { if (currentEditorState._selection !== null) { pendingEditorState._selection = currentEditorState._selection.clone(); } } else { pendingEditorState._selection = $internalCreateSelection(editor); } } const startingCompositionKey = editor._compositionKey; updateFn(); skipTransforms = processNestedUpdates(editor, skipTransforms); applySelectionTransforms(pendingEditorState, editor); if (editor._dirtyType !== NO_DIRTY_NODES) { if (skipTransforms) { $normalizeAllDirtyTextNodes(pendingEditorState, editor); } else { $applyAllTransforms(pendingEditorState, editor); } processNestedUpdates(editor); $garbageCollectDetachedNodes( currentEditorState, pendingEditorState, editor._dirtyLeaves, editor._dirtyElements, ); } const endingCompositionKey = editor._compositionKey; if (startingCompositionKey !== endingCompositionKey) { pendingEditorState._flushSync = true; } const pendingSelection = pendingEditorState._selection; if ($isRangeSelection(pendingSelection)) { const pendingNodeMap = pendingEditorState._nodeMap; const anchorKey = pendingSelection.anchor.key; const focusKey = pendingSelection.focus.key; if ( pendingNodeMap.get(anchorKey) === undefined || pendingNodeMap.get(focusKey) === undefined ) { invariant( false, 'updateEditor: selection has been lost because the previously selected nodes have been removed and ' + "selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.", ); } } else if ($isNodeSelection(pendingSelection)) { // TODO: we should also validate node selection? if (pendingSelection._nodes.size === 0) { pendingEditorState._selection = null; } } } catch (error) { // Report errors if (error instanceof Error) { editor._onError(error); } // Restore existing editor state to the DOM editor._pendingEditorState = currentEditorState; editor._dirtyType = FULL_RECONCILE; editor._cloneNotNeeded.clear(); editor._dirtyLeaves = new Set(); editor._dirtyElements.clear(); $commitPendingUpdates(editor); return; } finally { activeEditorState = previousActiveEditorState; isReadOnlyMode = previousReadOnlyMode; activeEditor = previousActiveEditor; editor._updating = previouslyUpdating; infiniteTransformCount = 0; } const shouldUpdate = editor._dirtyType !== NO_DIRTY_NODES || editorStateHasDirtySelection(pendingEditorState, editor); if (shouldUpdate) { if (pendingEditorState._flushSync) { pendingEditorState._flushSync = false; $commitPendingUpdates(editor); } else if (editorStateWasCloned) { scheduleMicroTask(() => { $commitPendingUpdates(editor); }); } } else { pendingEditorState._flushSync = false; if (editorStateWasCloned) { updateTags.clear(); editor._deferred = []; editor._pendingEditorState = null; } } } export function updateEditor( editor: LexicalEditor, updateFn: () => void, options?: EditorUpdateOptions, ): void { if (editor._updating) { editor._updates.push([updateFn, options]); } else { $beginUpdate(editor, updateFn, options); } }