resources/js/wysiwyg/lexical/core/LexicalUpdates.ts
/**
* 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<string>();
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<string, unknown>
).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<string, Array<Transform<LexicalNode>>>,
) {
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<InternalSerializedNode>;
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<V>(
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<string>,
dirtyLeaves: Set<string>,
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<Listener>(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<unknown>,
>(
editor: LexicalEditor,
type: TCommand,
payload: CommandPayloadType<TCommand>,
): 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);
}
}