UNPKG

lexical

Version:

Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.

1,375 lines (1,304 loc) 63.8 kB
/** * 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 {ElementDOMSlot} from './LexicalDOMSlot'; import type { EditorConfig, EditorDOMRenderConfig, LexicalEditor, MutatedNodes, MutationListeners, RegisteredNodes, } from './LexicalEditor'; import type { LexicalNode, LexicalPrivateDOM, NodeKey, NodeMap, } from './LexicalNode'; import type {ElementNode} from './nodes/LexicalElementNode'; import invariant from '@lexical/internal/invariant'; import { $isDecoratorNode, $isElementNode, $isLineBreakNode, $isRootNode, $isTextNode, DEFAULT_EDITOR_DOM_CONFIG, } from '.'; import { DOUBLE_LINE_BREAK, FULL_RECONCILE, IS_ALIGN_CENTER, IS_ALIGN_END, IS_ALIGN_JUSTIFY, IS_ALIGN_LEFT, IS_ALIGN_RIGHT, IS_ALIGN_START, } from './LexicalConstants'; import {EditorState} from './LexicalEditorState'; import {cloneMap} from './LexicalGenMap'; import { $createChildrenArray, $getDOMSlot, $isRootOrShadowRoot, cloneDecorators, getElementByKeyOrThrow, setMutatedNode, setNodeKeyOnDOMNode, } from './LexicalUtils'; const __DEV__ = process.env.NODE_ENV !== 'production'; type IntentionallyMarkedAsDirtyElement = boolean; /** * @internal * * A reconcile-managed cache of `getTextContentSize()` for leaf nodes. * * Stored as a Symbol-keyed property on the node instance itself so that * read/write are direct slot access. The slot is pre-allocated to * `undefined` as a non-enumerable property in the LexicalNode constructor * so all instances share the same V8 hidden-class shape and the setter is * a stable inline cache hit instead of a per-instance shape transition. * * ElementNodes are NOT stored here: an element can be dirty without being * cloned (a descendant edit marks ancestors dirty via * `internalMarkParentElementsAsDirty` but does not `getWritable()` them), so * the same — DEV-frozen — instance would need its size rewritten when its * text changes, which the skip-if-set guard cannot do. Element sizes come * from `dom.__lexicalTextContent` instead (see `$prevSuffixTextSize`). * * Leaf writes are skipped when the slot is already not `undefined`. The * setter is only re-entered for the same instance via cross-parent moves * (where the leaf is reused in a new parent without going through * `getWritable` — text is unchanged, so the prior cycle's value is still * correct). A leaf whose text actually changed went through * `getWritable()` and produced a fresh clone via `static clone(node)` -> * ctor -> fresh `undefined` slot, so the setter writes through normally. * * The reconciler sets this on every reconciled leaf at the end of * `$reconcileNode` (and on every newly-created leaf in `$createNode`), so * the previous editor state's leaves always carry a valid cached size from * the cycle that just committed. * * Suffix-incremental fast path reads this off the previous-state instance * to get the pre-reconcile size of dirty children in O(1), avoiding both * the `getLatest()` -> next-state trap and a recursive prev-tree walk. */ export const CACHED_TEXT_SIZE_KEY = Symbol.for('@lexical/CachedTextSize'); // Total previous-render text length of the `count` suffix children starting at // `startKey` (in next-map order, which equals prev order across the size-0 and // size-±1 fast paths). This is the slice length removed from the parent's // cached text before the freshly reconciled suffix is appended. // // The whole walk runs inside `activePrevEditorState.read(...)` so that every // node method resolves against the PREVIOUS node map: a moved element recomputes // its size via `getTextContentSize()` (its shared keyed-DOM cache may already // hold the NEW size, cf. https://github.com/facebook/lexical/pull/8564), and the // inter-sibling `isInline()` returns the node's previous-render value (a moved // or re-typed node could answer differently in the next state, and a node // removed this cycle would throw). The per-child size logic is inlined here // rather than shared so it cannot be called outside this read. Non-moved // elements and leaves still read their O(1) caches, so a large untouched suffix // child is not re-walked. function $prevSuffixTextSize(startKey: NodeKey, count: number): number { return activePrevEditorState.read( () => { let size = 0; let cur: NodeKey | null = startKey; for (let i = 0; i < count && cur !== null; i++) { const prevNode = activePrevNodeMap.get(cur); // Callers validate every suffix key is present in the prev map, so a // miss means a broken upstream invariant. Fail loudly (the reconciler // catch recovers via a full reconcile) rather than slice a partial sum. invariant( prevNode !== undefined, 'prevSuffixTextSize: missing prev node for key %s', cur, ); if ($isElementNode(prevNode)) { const nextNode = activeNextNodeMap.get(cur); if ( nextNode !== undefined && $isElementNode(nextNode) && nextNode.__parent !== prevNode.__parent ) { // Moved to a different parent this cycle: the shared keyed-DOM text // cache may already hold its NEW size, so recompute from the prev // tree. (`__parent === null` means detached/removed, not moved — its // DOM cache is still its prev text.) size += prevNode.getTextContentSize(); } else { const keyedDom = activePrevKeyToDOMMap.get(cur); const cached = keyedDom && keyedDom.__lexicalTextContent; invariant( typeof cached === 'string', 'prevSuffixTextSize: missing __lexicalTextContent for ElementNode of type %s', prevNode.getType(), ); size += cached.length; } if (i < count - 1 && !prevNode.isInline()) { size += DOUBLE_LINE_BREAK.length; } } else { // $reconcileNode / $createNode set the size on every leaf they touch, // so a missing entry means the invariant was broken upstream. const cached = prevNode[CACHED_TEXT_SIZE_KEY]; invariant( cached !== undefined, 'prevSuffixTextSize: missing cached size for leaf %s key %s', prevNode.getType(), cur, ); size += cached; } cur = prevNode.__next; } return size; }, {editor: activeEditor}, ); } function $setCachedTextSize(node: LexicalNode): void { if ($isElementNode(node)) { return; } // Skip if a value is already cached on this instance. The setter is only // re-entered for the same instance via cross-parent moves (where the leaf // is reused in a new parent without going through `getWritable` — text is // unchanged so the prior cycle's value is still correct), and that's // exactly the case where the instance is also frozen in DEV. if (node[CACHED_TEXT_SIZE_KEY] !== undefined) { return; } node[CACHED_TEXT_SIZE_KEY] = $isTextNode(node) ? node.__text.length : node.getTextContentSize(); } /** * Minimum children count for the suffix-incremental fast path to engage. * The fast path adds bookkeeping (cache lookups, suffix walks, splice) that * a few-children parent's general walk would beat — gate by a threshold so * the overhead only kicks in where the prefix preservation pays for it. * Tuned via `editorCycle.bench`. */ const MIN_FAST_PATH_CHILDREN = 4; /** * @internal * * Bench-only escape hatch. When `skipChildrenFastPath` is true the children * fast paths in `$reconcileChildren` are skipped and the general path * (`$reconcileNodeChildren`) runs instead — used by `editorCycle.bench.ts` * to produce a head-to-head A/B against the legacy walk in a single * `vitest bench` run. Has no effect when false (default). */ export const __benchOnly = { skipChildrenFastPath: false, }; let subTreeTextContent = ''; let subTreeTextFormat: number | null = null; let subTreeTextStyle: string | null = null; let subTreeFirstTextKey: NodeKey | null = null; // Save/restore guard for the leftmost-wins `subTreeFirstTextKey` // invariant. Any walk that recursively reconciles or creates element // children must wrap each iteration with `$beginCaptureGuard()` ... // `$endCaptureGuard(saved)` so the recursive scope's // `$reconcileChildrenWithDirection` reset doesn't clobber an // earlier sibling's captured first-text descriptor. // // Per-iteration object alloc relies on V8 escape analysis to keep // `CaptureGuard` off the heap — the shape is monomorphic and the // lifetime is deterministic, so stack alloc is the expected outcome. type CaptureGuard = { firstTextKey: NodeKey | null; format: number | null; style: string | null; }; function $beginCaptureGuard(): CaptureGuard { return { firstTextKey: subTreeFirstTextKey, format: subTreeTextFormat, style: subTreeTextStyle, }; } function $endCaptureGuard(saved: CaptureGuard): void { if (saved.firstTextKey !== null) { subTreeTextFormat = saved.format; subTreeTextStyle = saved.style; subTreeFirstTextKey = saved.firstTextKey; } } // Bubble a non-dirty element child's cached first-text descriptor up to // the caller's scope so a non-dirty prefix carrying the canonical first // text still wins over a later dirty sibling. Only fires when the // caller hasn't already captured one. // // `__lexicalFirstTextKey` is a reconciler-maintained cache that // `$createNode` / `$reconcileNode` set on every element's outer keyed // DOM. `null` means "this element has no text descendant" (legitimate — // empty element, decorator); `undefined` means the cache is missing, // which is an invariant violation worth surfacing loudly rather than // silently falling through and losing the leftmost-wins capture. function $bubbleChildFirstText( childKeyedDom: HTMLElement & LexicalPrivateDOM, ): void { if (subTreeFirstTextKey !== null) { return; } const childFirstKey = childKeyedDom.__lexicalFirstTextKey; invariant( childFirstKey !== undefined, '$bubbleChildFirstText: missing __lexicalFirstTextKey on element keyed DOM', ); if (childFirstKey === null) { return; } const textNode = activeNextNodeMap.get(childFirstKey); if ($isTextNode(textNode)) { subTreeTextFormat = textNode.getFormat(); subTreeTextStyle = textNode.getStyle(); subTreeFirstTextKey = childFirstKey; } } let activeEditorConfig: EditorConfig; let activeEditor: LexicalEditor; let activeEditorNodes: RegisteredNodes; let treatAllNodesAsDirty = false; let activeEditorStateReadOnly = false; let activeMutationListeners: MutationListeners; let activeDirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>; let activeDirtyLeaves: Set<NodeKey>; let activePrevNodeMap: NodeMap; let activePrevEditorState: EditorState; let activeNextNodeMap: NodeMap; let activePrevKeyToDOMMap: Map<NodeKey, HTMLElement & LexicalPrivateDOM>; let activeDirtyChildrenByParent: Map<NodeKey, Set<NodeKey>>; let mutatedNodes: MutatedNodes; let activeEditorDOMRenderConfig: EditorDOMRenderConfig; function $destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void { const node = activePrevNodeMap.get(key); // A node "moved" across parents in the same transaction still exists in // the next node map. We only detach its DOM from the old parent here; // the new parent's $createNode call will reuse it. Skip child destruction // and mutation marking — $reconcileNode will mark it 'updated' instead. const isMoved = activeNextNodeMap.has(key); if (parentDOM !== null) { const dom = getPrevElementByKeyOrThrow(key); if (dom.parentNode === parentDOM) { parentDOM.removeChild(dom); } } if (isMoved) { return; } // This logic is really important, otherwise we will leak DOM nodes // when their corresponding LexicalNodes are removed from the editor state. activeEditor._keyToDOMMap.delete(key); if ($isElementNode(node)) { const children = $createChildrenArray(node, activePrevNodeMap); $destroyChildren(children, 0, children.length - 1, null); } if (node !== undefined) { setMutatedNode( mutatedNodes, activeEditorNodes, activeMutationListeners, node, 'destroyed', ); } } function $destroyChildren( children: Array<NodeKey>, _startIndex: number, endIndex: number, dom: null | HTMLElement, ): void { for (let startIndex = _startIndex; startIndex <= endIndex; ++startIndex) { const child = children[startIndex]; if (child !== undefined) { $destroyNode(child, dom); } } } function setTextAlign(domStyle: CSSStyleDeclaration, value: string): void { domStyle.setProperty('text-align', value); } const DEFAULT_INDENT_VALUE = '40px'; function setElementIndent(dom: HTMLElement, indent: number): void { const indentClassName = activeEditorConfig.theme.indent; if (typeof indentClassName === 'string') { const elementHasClassName = dom.classList.contains(indentClassName); if (indent > 0 && !elementHasClassName) { dom.classList.add(indentClassName); } else if (indent < 1 && elementHasClassName) { dom.classList.remove(indentClassName); } } dom.style.setProperty( 'padding-inline-start', indent === 0 ? '' : `calc(${indent} * var(--lexical-indent-base-value, ${DEFAULT_INDENT_VALUE}))`, ); } function setElementFormat(dom: HTMLElement, format: number): void { const domStyle = dom.style; if (format === 0) { setTextAlign(domStyle, ''); } else if (format === IS_ALIGN_LEFT) { setTextAlign(domStyle, 'left'); } else if (format === IS_ALIGN_CENTER) { setTextAlign(domStyle, 'center'); } else if (format === IS_ALIGN_RIGHT) { setTextAlign(domStyle, 'right'); } else if (format === IS_ALIGN_JUSTIFY) { setTextAlign(domStyle, 'justify'); } else if (format === IS_ALIGN_START) { setTextAlign(domStyle, 'start'); } else if (format === IS_ALIGN_END) { setTextAlign(domStyle, 'end'); } } export function $getReconciledDirection( node: ElementNode, ): 'ltr' | 'rtl' | 'auto' | null { const direction = node.__dir; if (direction !== null) { return direction; } if ($isRootNode(node)) { return null; } const parent = node.getParentOrThrow(); if (!$isRootOrShadowRoot(parent) || parent.__dir !== null) { return null; } return 'auto'; } function $setElementDirection(dom: HTMLElement, node: ElementNode): void { const direction = $getReconciledDirection(node); if (direction !== null) { dom.dir = direction; } else { dom.removeAttribute('dir'); } } function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { const node = activeNextNodeMap.get(key); if (node === undefined) { invariant(false, 'createNode: node does not exist in nodeMap'); } // Cross-parent move: the same key existed in the previous tree under a // different parent. Reuse the existing DOM so React decorator portals, // contentEditable focus, etc. survive the reparenting. Without this the // DecoratorNode's wrapper is recreated and React unmounts/remounts the // child component (visible as a 1-frame flicker in Safari). // Requires a slot so $reconcileNode has a valid parentDOM in case the // moved node also reports updateDOM=true and needs an in-place replace. if (slot !== null) { const prevNode = activePrevNodeMap.get(key); if (prevNode !== undefined && prevNode.__parent !== node.__parent) { const existingDOM = activePrevKeyToDOMMap.get(key); if (existingDOM !== undefined) { slot.insertChild(existingDOM); return $reconcileNode(key, slot.element); } } } const dom: HTMLElement & LexicalPrivateDOM = activeEditorDOMRenderConfig.$createDOM(node, activeEditor); storeDOMWithKey(key, dom, activeEditor); // This helps preserve the text, and stops spell check tools from // merging or break the spans (which happens if they are missing // this attribute). if ($isTextNode(node)) { dom.setAttribute('data-lexical-text', 'true'); } else if ($isDecoratorNode(node)) { dom.setAttribute('data-lexical-decorator', 'true'); } if ($isElementNode(node)) { const indent = node.__indent; const childrenSize = node.__size; $setElementDirection(dom, node); if (indent !== 0) { setElementIndent(dom, indent); } if (childrenSize === 0) { // Empty element: $createChildren's cache write is skipped, so set // the cache explicitly on the keyed DOM. Symmetric with the // (keyed-DOM) writes in $createChildren / $reconcileChildren. dom.__lexicalTextContent = ''; dom.__lexicalFirstTextKey = null; } else { const endIndex = childrenSize - 1; const children = $createChildrenArray(node, activeNextNodeMap); $createChildren( children, node, 0, endIndex, $getDOMSlot(node, dom, activeEditor), ); } const format = node.__format; if (format !== 0) { setElementFormat(dom, format); } if (!node.isInline()) { $reconcileElementTerminatingLineBreak(null, node, dom); } } else { const text = node.getTextContent(); if ($isDecoratorNode(node)) { const decorator = node.decorate(activeEditor, activeEditorConfig); if (decorator !== null) { reconcileDecorator(key, decorator); } // Decorators are always non editable dom.contentEditable = 'false'; } subTreeTextContent += text; } if (slot !== null) { slot.insertChild(dom); } activeEditorDOMRenderConfig.$decorateDOM(node, null, dom, activeEditor); // Same cached-text-size invariant as $reconcileNode — every node leaving // a reconciler entry point in the next state carries a current label. $setCachedTextSize(node); if (__DEV__) { // Freeze the node in DEV to prevent accidental mutations Object.freeze(node); } setMutatedNode( mutatedNodes, activeEditorNodes, activeMutationListeners, node, 'created', ); return dom; } function $createChildren( children: Array<NodeKey>, element: ElementNode, _startIndex: number, endIndex: number, slot: ElementDOMSlot, ): void { // Save outer scope and reset module state so this walk's // `dom.__lexicalFirstTextKey` write only reflects descendants captured // here, not a leaked first-text key from an earlier sibling's outer // walk. Mirrors what `$reconcileChildrenWithDirection` does at entry. const previousSubTreeTextContent = subTreeTextContent; const outerSaved = $beginCaptureGuard(); subTreeTextContent = ''; subTreeTextFormat = null; subTreeTextStyle = null; subTreeFirstTextKey = null; let startIndex = _startIndex; for (; startIndex <= endIndex; ++startIndex) { const saved = $beginCaptureGuard(); $createNode(children[startIndex], slot); const node = activeNextNodeMap.get(children[startIndex]); if (node !== null && $isTextNode(node)) { if (subTreeTextFormat === null) { subTreeTextFormat = node.getFormat(); subTreeTextStyle = node.getStyle(); subTreeFirstTextKey = node.__key; } } else if ( // inline $textContentRequiresDoubleLinebreakAtEnd $isElementNode(node) && startIndex < endIndex && !node.isInline() ) { subTreeTextContent += DOUBLE_LINE_BREAK; } $endCaptureGuard(saved); } // Cache lives on the keyed DOM (outer wrapper) for wrapping elements; // identical to `slot.element` otherwise. Look up rather than thread a // parameter — the element's DOM is already in the map via // `storeDOMWithKey` by the time we get here. const cacheDom = activeEditor._keyToDOMMap.get(element.__key); invariant( cacheDom !== undefined, '$createChildren: Element with key %s missing from keyToDOMMap', element.__key, ); cacheDom.__lexicalTextContent = subTreeTextContent; cacheDom.__lexicalFirstTextKey = subTreeFirstTextKey; subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; // Outer-scope leftmost-wins: if the caller already had a first text // captured, restore it. Otherwise leave this walk's first-text in the // module state so the caller's outer walk picks it up. $endCaptureGuard(outerSaved); } type LastChildState = 'line-break' | 'decorator' | 'empty'; function isLastChildLineBreakOrDecorator( element: null | ElementNode, nodeMap: NodeMap, ): null | LastChildState { if (element) { const lastKey = element.__last; if (lastKey) { const node = nodeMap.get(lastKey); if (node) { return $isLineBreakNode(node) ? 'line-break' : $isDecoratorNode(node) && node.isInline() ? 'decorator' : null; } } return 'empty'; } return null; } // If we end an element with a LineBreakNode, then we need to add an additional <br> function $reconcileElementTerminatingLineBreak( prevElement: null | ElementNode, nextElement: ElementNode, dom: HTMLElement & LexicalPrivateDOM, ): void { // Read previous render's last-child kind from the slot element's cache // so the prev-state DecoratorNode reference's isInline() (which routes // through getLatest() and would throw once the key is detached from the // active node map) is never called. const slot = $getDOMSlot(nextElement, dom, activeEditor); const slotElement: HTMLElement & LexicalPrivateDOM = slot.element; const prevLineBreak = slotElement.__lexicalLastChildKind ?? null; const nextLineBreak = isLastChildLineBreakOrDecorator( nextElement, activeNextNodeMap, ); if (prevLineBreak !== nextLineBreak) { slot.setManagedLineBreak(nextLineBreak); } } function reconcileTextFormat(element: ElementNode): void { if ( subTreeTextFormat != null && subTreeTextFormat !== element.__textFormat && !activeEditorStateReadOnly ) { element.setTextFormat(subTreeTextFormat); } } function reconcileTextStyle(element: ElementNode): void { if ( subTreeTextStyle != null && subTreeTextStyle !== element.__textStyle && !activeEditorStateReadOnly ) { element.setTextStyle(subTreeTextStyle); } } function $reconcileChildrenWithDirection( prevElement: ElementNode, nextElement: ElementNode, dom: HTMLElement, ): void { subTreeTextFormat = null; subTreeTextStyle = null; subTreeFirstTextKey = null; $reconcileChildren( prevElement, nextElement, $getDOMSlot(nextElement, dom, activeEditor), ); if (!$isRootOrShadowRoot(nextElement)) { // RootNode / ShadowRootNode never expose `__textFormat` / `__textStyle` // to user code: `LexicalElementNode.exportJSON` excludes them (#7968) // and selection inheritance only reads element format/style for // empty-element anchors gated on `!isRootTextContentEmpty`. Skipping // reconcile here keeps the invariant aligned and sidesteps the // suffix-fast-path's stale-format edge case at the root level. reconcileTextFormat(nextElement); reconcileTextStyle(nextElement); } } function $buildDirtyChildrenByParent(): Map<NodeKey, Set<NodeKey>> { const map = new Map<NodeKey, Set<NodeKey>>(); const addKeysToMap = (keys: Iterable<NodeKey>): void => { for (const key of keys) { const node = activeNextNodeMap.get(key); if (node === undefined) { continue; } const parentKey = node.__parent; if (parentKey === null) { continue; } let set = map.get(parentKey); if (set === undefined) { set = new Set(); map.set(parentKey, set); } set.add(key); } }; addKeysToMap(activeDirtyElements.keys()); addKeysToMap(activeDirtyLeaves); return map; } // Returns the key of the first child in the K-element suffix if all dirty // children form a contiguous suffix of `parent` (and 0 < K < total children). // Returns null otherwise — caller falls back to the full-walk fast path. function $suffixStartIfContiguous( parent: ElementNode, dirty: Set<NodeKey>, ): NodeKey | null { const k = dirty.size; if (k === 0 || k >= parent.__size) { return null; } let cur: NodeKey | null = parent.__last; let suffixStart: NodeKey | null = null; let i = 0; while (cur !== null && i < k) { if (!dirty.has(cur)) { return null; } suffixStart = cur; const node = activeNextNodeMap.get(cur); if (node === undefined) { return null; } cur = node.__prev; i++; } if (i !== k) { return null; } // The element immediately before the suffix must be non-dirty // (cur === null is excluded by the k < parent.__size check above). if (cur !== null && dirty.has(cur)) { return null; } return suffixStart; } // Suffix-incremental fast path for ±1 children-size mutations. // Two structural patterns are supported (others bail to the general path): // - sizeDelta=+1, K=2: append at end, or end-split where one node // becomes two. Last 2 children of `nextElement` are dirty; one prev // child corresponds. // - sizeDelta=-1, K=1: boundary-collapse (e.g. backspace at the start // of a block merging into the previous). Last 1 child of `nextElement` // is dirty; two prev children correspond. // (The same-size sizeDelta=0 case is inlined in `$reconcileChildren` and // uses the same splice math with a simpler suffix walk.) // // Returns true if the cache was spliced and DOM mutated; false on bail // (K mismatch, boundary mismatch, or out-of-order suffix overlap), in // which case the caller falls through to `$reconcileNodeChildren`. function $tryReconcileSuffixWithSizeDelta( prevElement: ElementNode, nextElement: ElementNode, slot: ElementDOMSlot, cacheDom: HTMLElement & LexicalPrivateDOM, cachedParentText: string, suffixStartKey: NodeKey, k: number, sizeDelta: number, ): boolean { // `slot.element` is the inner DOM where children live and where DOM // operations (replaceChild / removeChild / insertBefore) must target; // `cacheDom` is the outer keyed DOM that holds the parent's text-content // cache. For non-wrapping ElementNodes they're the same element; for // wrapping nodes (e.g. TableNode with a scrollable wrapper) they differ // and routing each role to the right element matters for correctness. // Caller invariant: this helper only handles ±1 children-size mutations. // Bailing on anything else preserves defense-in-depth in case the // upstream gate ever loosens. if (sizeDelta !== 1 && sizeDelta !== -1) { return false; } // Only the two patterns above are supported; e.g. K=3 dirty after a // split-into-three, or K=1 with sizeDelta=+1 (pure append with no // sibling cloned for `__next` link), all bail. const expectedK = sizeDelta === 1 ? 2 : 1; if (k !== expectedK) { return false; } // K' = K − sizeDelta: delta=+1, K=2 → K'=1; delta=-1, K=1 → K'=2. const kPrime = k - sizeDelta; let prevSuffixStartKey: NodeKey | null = prevElement.__last; for (let i = 0; i < kPrime - 1; i++) { if (prevSuffixStartKey === null) { return false; } const node = activePrevNodeMap.get(prevSuffixStartKey); if (node === undefined) { return false; } prevSuffixStartKey = node.__prev; } if (prevSuffixStartKey === null) { return false; } const nextStartNode = activeNextNodeMap.get(suffixStartKey); const prevStartNode = activePrevNodeMap.get(prevSuffixStartKey); if (nextStartNode === undefined || prevStartNode === undefined) { return false; } // Boundary identity: the node immediately before the suffix in next must // match the corresponding node in prev. Both null (suffix starts at first // child) is a match too. if (nextStartNode.__prev !== prevStartNode.__prev) { return false; } const nextSuffixKeys: NodeKey[] = []; let cur: NodeKey | null = suffixStartKey; for (let i = 0; i < k; i++) { if (cur === null) { return false; } nextSuffixKeys.push(cur); const node = activeNextNodeMap.get(cur); cur = node ? node.__next : null; } const prevSuffixKeys: NodeKey[] = []; cur = prevSuffixStartKey; for (let i = 0; i < kPrime; i++) { if (cur === null) { return false; } prevSuffixKeys.push(cur); const node = activePrevNodeMap.get(cur); cur = node ? node.__next : null; } // Two-pointer walk to validate ordering and plan ops in next-order. // Bail if a key is in both suffixes but at different positions (reorder). const prevSet = new Set(prevSuffixKeys); const nextSet = new Set(nextSuffixKeys); type SuffixOp = | {kind: 'reconcile'; key: NodeKey} | {kind: 'create'; key: NodeKey; nextIndex: number} | {kind: 'destroy'; key: NodeKey}; const ops: SuffixOp[] = []; let pi = 0; let ni = 0; while (pi < kPrime && ni < k) { if (nextSuffixKeys[ni] === prevSuffixKeys[pi]) { ops.push({key: nextSuffixKeys[ni], kind: 'reconcile'}); pi++; ni++; } else if (!nextSet.has(prevSuffixKeys[pi])) { ops.push({key: prevSuffixKeys[pi], kind: 'destroy'}); pi++; } else if (!prevSet.has(nextSuffixKeys[ni])) { ops.push({key: nextSuffixKeys[ni], kind: 'create', nextIndex: ni}); ni++; } else { return false; } } while (pi < kPrime) { ops.push({key: prevSuffixKeys[pi++], kind: 'destroy'}); } while (ni < k) { ops.push({key: nextSuffixKeys[ni], kind: 'create', nextIndex: ni}); ni++; } // `prevSuffixKeys` was built above by walking the prev map from // `prevSuffixStartKey`, so every key is present there and the helper // reproduces the same `kPrime`-length traversal. const oldSuffixLength = $prevSuffixTextSize(prevSuffixStartKey, kPrime); for (const op of ops) { const saved = $beginCaptureGuard(); if (op.kind === 'reconcile') { $reconcileNode(op.key, slot.element); } else if (op.kind === 'destroy') { $destroyNode(op.key, slot.element); } else { let beforeDOM: Node | null = null; for (let j = op.nextIndex + 1; j < k; j++) { const siblingDOM = activeEditor._keyToDOMMap.get(nextSuffixKeys[j]); if (siblingDOM !== undefined) { beforeDOM = siblingDOM; break; } } // No lexical sibling found: insertion goes at the end of the lexical // range, which is still bounded by `slot.before` for slots carrying a // trailing non-lexical decoration (e.g. a drag handle pinned as the // last DOM child of the parent). Falling back to `slot.before` keeps // those decorations behind the new child. $createNode(op.key, slot.withBefore(beforeDOM ?? slot.before)); } if (op.kind !== 'destroy') { const opNode = activeNextNodeMap.get(op.key); if (opNode && $isTextNode(opNode) && subTreeTextFormat === null) { subTreeTextFormat = opNode.getFormat(); subTreeTextStyle = opNode.getStyle(); subTreeFirstTextKey = opNode.__key; } } $endCaptureGuard(saved); } let newSuffix = ''; for (let i = 0; i < k; i++) { const node = activeNextNodeMap.get(nextSuffixKeys[i]); if (node === undefined) { return false; } let text: string; if ($isElementNode(node)) { const childKeyedDom = activeEditor._keyToDOMMap.get(nextSuffixKeys[i]); const cached = childKeyedDom && childKeyedDom.__lexicalTextContent; invariant( typeof cached === 'string', 'tryReconcileSuffixWithSizeDelta: missing __lexicalTextContent on child of type %s after suffix reconcile', node.getType(), ); text = cached; } else { text = node.getTextContent(); } newSuffix += text; if (i < k - 1 && $isElementNode(node) && !node.isInline()) { newSuffix += DOUBLE_LINE_BREAK; } } const newParentText = cachedParentText.slice(0, cachedParentText.length - oldSuffixLength) + newSuffix; cacheDom.__lexicalTextContent = newParentText; return true; } /** * Decide whether the post-suffix-walk values of `subTreeTextFormat` / * `subTreeTextStyle` should be kept (the prefix has no text descendant * and the suffix carries the canonical first text) or replaced with the * prev-cycle's canonical values (the prefix is still authoritative). * * The cached `__lexicalFirstTextKey` on `dom` is the deep TextNode key * recorded when this element's children were last walked. We climb its * ancestor chain in next-state until we reach a direct child of * `nextElement`, then probe `dirtyChildren`: if that direct child is * dirty (or the cached key is missing from the next map), the cached * key has been moved into the suffix's subtree or destroyed, so the * suffix-derived values are authoritative. Otherwise the prefix is * canonical and we recover format/style from the live text node, which * lets `reconcileTextFormat` / `reconcileTextStyle` no-op via their * existing equality check against the parent's `__textFormat` / * `__textStyle`. * * Walk depth is bounded by tree depth from the text node to the * reconciled element (typically 1 — text directly under a paragraph). * Always refreshes the cache for the next cycle. */ function $resolveSuffixPathFormat( nextElement: ElementNode, dom: HTMLElement & LexicalPrivateDOM, dirtyChildren: Set<NodeKey>, ): void { const cachedFirstTextKey = dom.__lexicalFirstTextKey; if (cachedFirstTextKey != null) { const parentKey = nextElement.__key; let ancestor: NodeKey | null = cachedFirstTextKey; while (ancestor !== null) { const node = activeNextNodeMap.get(ancestor); if (node === undefined) { ancestor = null; break; } if (node.__parent === parentKey) { break; } ancestor = node.__parent; } if (ancestor !== null && !dirtyChildren.has(ancestor)) { const textNode = activeNextNodeMap.get(cachedFirstTextKey); if ($isTextNode(textNode)) { // Prefix carries the canonical first text descendant. Recover // format/style from the live next-state node — `reconcileTextFormat` // will compare against `nextElement.__textFormat` and no-op when // the prev cycle's value is still correct. subTreeTextFormat = textNode.getFormat(); subTreeTextStyle = textNode.getStyle(); // Cache key is unchanged this cycle. return; } } } // Either no prev text descendant, ancestor not found, or ancestor is // dirty. Keep the suffix-derived `subTreeTextFormat` / `subTreeTextStyle` // so reconcileTextFormat updates the parent (or no-ops on root / // shadow root via the gate). Refresh the cache to reflect this cycle's // first text descendant, recorded by the recursive suffix-child walks // into `subTreeFirstTextKey`. dom.__lexicalFirstTextKey = subTreeFirstTextKey; } function $reconcileChildren( prevElement: ElementNode, nextElement: ElementNode, slot: ElementDOMSlot, ): void { const previousSubTreeTextContent = subTreeTextContent; const prevChildrenSize = prevElement.__size; const nextChildrenSize = nextElement.__size; subTreeTextContent = ''; // `dom` is `slot.element` (the inner DOM where children live and where // DOM operations target). `cacheDom` is the keyed DOM (outer wrapper // for nodes that wrap, identical to `dom` otherwise) and holds the // `__lexicalTextContent` / `__lexicalFirstTextKey` caches for this // element. Keeping them split lets wrapping nodes (TableNode etc.) // route cache R/W to the outer DOM while DOM ops stay on the slot. const dom: HTMLElement & LexicalPrivateDOM = slot.element; const cacheDom = activeEditor._keyToDOMMap.get(nextElement.__key); invariant( cacheDom !== undefined, '$reconcileChildren: Element with key %s missing from keyToDOMMap', nextElement.__key, ); const sizeDelta = nextChildrenSize - prevChildrenSize; if ( !__benchOnly.skipChildrenFastPath && // A FULL_RECONCILE (e.g. `setEditorState`, which backs history // undo/redo) swaps the whole node map wholesale without routing // structural changes through `getWritable()`, so `_cloneNotNeeded` // is empty even when prev and next children differ by key. That // breaks the `sizeDelta === 0` walk below, which starts at // `prevElement.__first` but advances via the next map's `__next` // pointers — assuming both lists hold the same keys in the same // order. With a same-size key swap (undo replacing a CodeNode with // the paragraphs it came from) the walk reaches a next-only key and // `$reconcileNode` throws on the missing prev node (#8563). Dirty // tracking is meaningless in this mode anyway, so fall through to the // general key-diffing path. !treatAllNodesAsDirty && Math.abs(sizeDelta) <= 1 && prevChildrenSize >= MIN_FAST_PATH_CHILDREN && prevElement.__first === nextElement.__first && // For sizeDelta=0 the parent must not have been cloned this cycle — // any structural mutation routed through Lexical's mutation API // (insertBefore/insertAfter/replace/remove/append etc.) keeps the // parent in `_cloneNotNeeded` via `getWritable()`, so this single // check already covers a stale `__last` for those cases. Direct // pointer mutation that bypasses `getWritable()` is outside the // contract and not guarded against here. For sizeDelta=±1 the // parent is always cloned (its `__size` mutation goes through // `getWritable`), so the same check would dead-code that branch. (sizeDelta !== 0 || !activeEditor._cloneNotNeeded.has(prevElement.__key)) ) { // Suffix-incremental fast path: when the dirty children form a // contiguous suffix and the parent already has a valid cached text, // splice the new suffix into the cache instead of walking every child. // The non-dirty prefix (and its DLB into the suffix) stays untouched, // so format/style propagation — which captures the first text descendant // — is unaffected. const cachedParentText = cacheDom.__lexicalTextContent; const dirtyChildren = activeDirtyChildrenByParent.get(prevElement.__key); if ( !treatAllNodesAsDirty && typeof cachedParentText === 'string' && dirtyChildren !== undefined ) { const suffixStartKey = $suffixStartIfContiguous( nextElement, dirtyChildren, ); if (suffixStartKey !== null) { const k = dirtyChildren.size; if (sizeDelta === 0) { // Same keys in the same order across prev and next (gated by // `prevElement.__first === nextElement.__first`, no clone), so the // prev-map walk visits exactly this suffix. const oldSuffixLength = $prevSuffixTextSize(suffixStartKey, k); let cur: NodeKey | null = suffixStartKey; let i = 0; while (cur !== null && i < k) { const node = activeNextNodeMap.get(cur); if (node === undefined) { break; } const saved = $beginCaptureGuard(); $reconcileNode(cur, dom); if ($isTextNode(node) && subTreeTextFormat === null) { subTreeTextFormat = node.getFormat(); subTreeTextStyle = node.getStyle(); subTreeFirstTextKey = node.__key; } $endCaptureGuard(saved); cur = node.__next; i++; } let newSuffix = ''; cur = suffixStartKey; i = 0; while (cur !== null && i < k) { const node = activeNextNodeMap.get(cur); if (node === undefined) { break; } let text: string; if ($isElementNode(node)) { // Read from the current keyed DOM map, not the prev snapshot. // The just-completed reconcile loop above can fire // `$reconcileNode`'s `parentDOM.replaceChild` branch when a // dirty child's `$updateDOM` returns true (e.g. `ListNode` // toggling `__tag` / `__listType`); the snapshot would still // point at the detached old DOM whose `__lexicalTextContent` // is from the previous cycle. Mirrors the size-delta helper // at L856. const childKeyedDom = activeEditor._keyToDOMMap.get(cur); const cached = childKeyedDom && childKeyedDom.__lexicalTextContent; invariant( typeof cached === 'string', 'reconcileChildren same-size suffix: missing __lexicalTextContent on child of type %s after reconcile', node.getType(), ); text = cached; } else { text = node.getTextContent(); } newSuffix += text; if (i < k - 1 && $isElementNode(node) && !node.isInline()) { newSuffix += DOUBLE_LINE_BREAK; } cur = node.__next; i++; } const newParentText = cachedParentText.slice( 0, cachedParentText.length - oldSuffixLength, ) + newSuffix; cacheDom.__lexicalTextContent = newParentText; subTreeTextContent = previousSubTreeTextContent + newParentText; // Recover the canonical first-text format/style for this parent. // If the prefix carries it, `reconcileTextFormat` no-ops via // equality. If the prefix has no text descendant, the // suffix-derived values stay and propagate correctly. $resolveSuffixPathFormat(nextElement, cacheDom, dirtyChildren); return; } if ( $tryReconcileSuffixWithSizeDelta( prevElement, nextElement, slot, cacheDom, cachedParentText, suffixStartKey, k, sizeDelta, ) ) { // Helper returns true only after writing cacheDom.__lexicalTextContent // (helper body's final line). Match the PR-wide strict-on-miss // policy rather than masking a future regression with `?? ''`. const newCachedText = cacheDom.__lexicalTextContent; invariant( typeof newCachedText === 'string', 'reconcileChildren: $tryReconcileSuffixWithSizeDelta returned true without writing __lexicalTextContent', ); subTreeTextContent = previousSubTreeTextContent + newCachedText; $resolveSuffixPathFormat(nextElement, cacheDom, dirtyChildren); return; } // Bail: helper rejected the size-delta candidate (K mismatch, // boundary mismatch, or out-of-order suffix overlap). Fall through // to the outer general path. } } if (sizeDelta === 0) { let nodeKey: NodeKey | null = prevElement.__first; let i = 0; while (nodeKey !== null) { const node = activeNextNodeMap.get(nodeKey); if (node === undefined) { break; } const isDirty = treatAllNodesAsDirty || activeDirtyLeaves.has(nodeKey) || activeDirtyElements.has(nodeKey); const saved = $beginCaptureGuard(); if (isDirty) { $reconcileNode(nodeKey, dom); } else { // Subtree is structurally and content-clean — accumulate the // cached text from the existing DOM rather than walking back // through `$reconcileNode`. let text: string; let childKeyedDom: undefined | (HTMLElement & LexicalPrivateDOM); if ($isElementNode(node)) { childKeyedDom = activePrevKeyToDOMMap.get(nodeKey); const cached = childKeyedDom && childKeyedDom.__lexicalTextContent; invariant( typeof cached === 'string', 'reconcileChildren structurally-clean walk: missing __lexicalTextContent on non-dirty child of type %s', node.getType(), ); text = cached; } else { text = node.getTextContent(); } subTreeTextContent += text; if (childKeyedDom !== undefined) { $bubbleChildFirstText(childKeyedDom); } } if ($isTextNode(node)) { if (subTreeTextFormat === null) { subTreeTextFormat = node.getFormat(); subTreeTextStyle = node.getStyle(); subTreeFirstTextKey = node.__key; } } else if ( $isElementNode(node) && i < nextChildrenSize - 1 && !node.isInline() ) { subTreeTextContent += DOUBLE_LINE_BREAK; } $endCaptureGuard(saved); nodeKey = node.__next; i++; } cacheDom.__lexicalTextContent = subTreeTextContent; cacheDom.__lexicalFirstTextKey = subTreeFirstTextKey; subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; return; } // sizeDelta !== 0 with no successful suffix-incremental path: fall // through to the outer general walk (`$reconcileNodeChildren`), which // handles arbitrary size changes. } if (prevChildrenSize === 1 && nextChildrenSize === 1) { const prevFirstChildKey: NodeKey = prevElement.__first!; const nextFirstChildKey: NodeKey = nextElement.__first!; if (prevFirstChildKey === nextFirstChildKey) { $reconcileNode(prevFirstChildKey, dom); } else { const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey); const replacementDOM = $createNode(nextFirstChildKey, null); try { if (lastDOM.parentNode === dom) { dom.replaceChild(replacementDOM, lastDOM); } else { // lastDOM was reused as a descendant of replacementDOM (cross-parent // move, e.g. wrapping an image in a link). It's already detached // from `dom`, so just insert the replacement. slot.insertChild(replacementDOM); } } catch (error) { if (typeof error === 'object' && error != null) { const msg = `${error.toString()} Parent: ${ dom.tagName }, new child: {tag: ${ replacementDOM.tagName } key: ${nextFirstChildKey}}, old child: {tag: ${ lastDOM.tagName }, key: ${prevFirstChildKey}}.`; throw new Error(msg); } else { throw error; } } $destroyNode(prevFirstChildKey, null); } const nextChildNode = activeNextNodeMap.get(nextFirstChildKey); if ($isTextNode(nextChildNode)) { if (subTreeTextFormat === null) { subTreeTextFormat = nextChildNode.getFormat(); subTreeTextStyle = nextChildNode.getStyle(); subTreeFirstTextKey = nextChildNode.__key; } } } else { const prevChildren = $createChildrenArray(prevElement, activePrevNodeMap); const nextChildren = $createChildrenArray(nextElement, activeNextNodeMap); invariant( prevChildren.length === prevChildrenSize, '$reconcileChildren: prevChildren.length !== prevChildrenSize', ); invariant( nextChildren.length === nextChildrenSize, '$reconcileChildren: nextChildren.length !== nextChildrenSize', ); if (prevChildrenSize === 0) { if (nextChildrenSize !== 0) { $createChildren( nextChildren, nextElement, 0, nextChildrenSize - 1, slot, ); } } else if (nextChildrenSize === 0) { if (prevChildrenSize !== 0) { const canUseFastPath = slot.after == null && slot.before == null && (slot.element as HTMLElement & LexicalPrivateDOM) .__lexicalLineBreak == null; $destroyChildren( prevChildren, 0, prevChildrenSize - 1, canUseFastPath ? null : dom, ); if (canUseFastPath) { // Fast path for removing DOM nodes dom.textContent = ''; } } } else { $reconcileNodeChildren( nextElement, prevChildren, nextChildren, prevChildrenSize, nextChildrenSize, slot, ); } } cacheDom.__lexicalTextContent = subTreeTextContent; cacheDom.__lexicalFirstTextKey = subTreeFirstTextKey; subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; } function $reconcileNode( key: NodeKey, parentDOM: HTMLElement | null, ): HTMLElement { const prevNode = activePrevNodeMap.get(key); let nextNode = activeNextNodeMap.get(key); if (prevNode === undefined || nextNode === undefined) { invariant( false, 'reconcileNode: prevNode or nextNode does not exist in nodeMap', ); } const isDirty = treatAllNodesAsDirty || activeDirtyLeaves.has(key) || activeDirtyElements.has(key); const dom: HTMLElement & LexicalPrivateDOM = getElementByKeyOrThrow( activeEditor, key, ); // If the node key points to the same instance in both states // and isn't dirty, we just update the text content cache // and return the existing DOM Node. if (prevNode === nextNode && !isDirty) { let text: string; if ($isElementNode(prevNode)) { const previousSubTreeTextContent = dom.__lexicalTextContent; // Strict invariant — every element reconciled in a previous cycle has // both `__lexicalTextContent` and `__lexicalFirstTextKey` set on its // keyed DOM by `$createNode` / `$reconcileChildren`. A missing cache // here would silently desync the parent text accumulation and pair // with `$bubbleChildFirstText`'s own strict invariant