lexical
Version:
Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.
1,306 lines (1,240 loc) • 81.9 kB
text/typescript
/**
* 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 {DOMSlot, ElementDOMSlot} from './LexicalDOMSlot';
import type {
EditorConfig,
EditorDOMRenderConfig,
LexicalEditor,
MutatedNodes,
MutationListeners,
RegisteredNodes,
} from './LexicalEditor';
import type {EditorState} from './LexicalEditorState';
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 {cloneMap} from './LexicalGenMap';
import {$isSlotChild, $isSlotHost, EMPTY_SLOTS} from './LexicalSlot';
import {
$createChildrenArray,
$getDocument,
$getDOMSlot,
$isRootOrShadowRoot,
$markSlotEditable,
cloneDecorators,
getElementByKeyOrThrow,
setDOMUnmanaged,
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);
}
// Slots are a separate channel from the linked-list children, so the
// recursion above never reaches them. Destroy each slot subtree too —
// otherwise its _keyToDOMMap entries leak and no 'destroyed' mutation fires
// for slot nodes. Resolve the container before destroying the subtree (the
// key is gone from the DOM map after). A decorator host's container is
// detached (relocated into the decorate() chrome) so remove it explicitly;
// an element host's container sits inside the host DOM already being removed,
// where remove() is a harmless no-op on a detached parent.
if (node !== undefined) {
for (const slotKey of $readSlots(node).values()) {
const container = $slotContainerForKey(slotKey);
$destroyNode(slotKey, null);
if (container !== null) {
container.remove();
}
}
setMutatedNode(
mutatedNodes,
activeEditorNodes,
activeMutationListeners,
node,
'destroyed',
);
}
}
function $destroyChildren(
children: 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.getParent();
if (parent === null) {
// A slotted node has no parent (its up-pointer is __slotHost); it is
// the root of an isolated slot subtree, so it behaves like a
// top-level block and bidi-auto-detects, matching the root-child
// case below. In non-slot trees every non-root element has a parent,
// so this branch is unreachable and behavior is unchanged.
return 'auto';
}
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');
}
}
// @experimental named-slots. Slots are a separate channel from the
// linked-list children: each slot subtree renders into its own
// non-keyed container, and its text is concatenated with no separator to
// match `ElementNode.getTextContent`. Containers are synchronous hidden
// placeholders: they mount slots-first in the host DOM with
// `display: 'none'` so every slot subtree is always rendered and part of
// the document, but nothing is visible until the host explicitly attaches
// the container somewhere with `mountSlotContainer` (directly or through
// lexical-react's `useLexicalSlotRef`), which reveals it — mirroring how
// `getDOMSlot` gives an element control over where its linked-list children
// render. Only the wrapper is
// scaffolding; the slot subtree inside carries its own NodeKey and
// reconciles normally.
// Leaves `subTreeTextContent` unchanged (restored on exit); the caller folds
// the returned text in slots-first.
// @experimental named-slots. Build a hidden slot placeholder element (DOM only,
// no Lexical state), shared by the mount and reconcile paths so the two never
// drift. The container is left unattached — the caller inserts it (appended on a
// fresh mount, slots-first on reconcile) — and starts `display: none`, revealed
// only by an explicit mount / $getSlotTargetElement. Editability is applied
// separately by $applySlotEditable.
function $createSlotDOM(name: string): HTMLElement {
const container = $getDocument().createElement('div');
container.setAttribute('data-lexical-slot', name);
container.style.display = 'none';
return container;
}
// Apply a slot container's editability. Re-run on every (re)mount — including
// the reconcile-reuse path — so a reused container can never keep a stale value.
// Inside a non-editable host (a decorator, or an element shell that wraps
// editable islands in chrome) the container is an island that would not track
// the editor on its own, so it carries an explicit `contentEditable` following
// the editor via `$markSlotEditable`; re-applying on every reconcile is what
// carries an editable toggle (a `$fullReconcile`) into the DOM. Otherwise the
// host is editable and the container inherits, so any stale `contentEditable`
// from a previous host state is cleared.
function $applySlotEditable(
hostDom: HTMLElement,
decoratorHost: boolean,
container: HTMLElement,
): void {
if (decoratorHost || hostDom.contentEditable === 'false') {
$markSlotEditable(container, activeEditor);
} else {
container.removeAttribute('contenteditable');
}
}
function $mountSlotChildren(
node: LexicalNode,
hostDom: HTMLElement,
slots: ReadonlyMap<string, NodeKey>,
): string {
const previousSubTreeTextContent = subTreeTextContent;
const outerSaved = $beginCaptureGuard();
subTreeTextContent = '';
let totalText = '';
const decoratorHost = $isDecoratorNode(node);
for (const [name, slotKey] of slots) {
const container = $createSlotDOM(name);
$applySlotEditable(hostDom, decoratorHost, container);
hostDom.appendChild(container);
subTreeTextContent = '';
const saved = $beginCaptureGuard();
$createNode(slotKey, $getDOMSlot(node, container, activeEditor));
$endCaptureGuard(saved);
$applySlotTarget(node, name, hostDom, container);
totalText += subTreeTextContent;
}
$endCaptureGuard(outerSaved);
subTreeTextContent = previousSubTreeTextContent;
return totalText;
}
function $readSlots(node: LexicalNode): ReadonlyMap<string, NodeKey> {
return $isSlotHost(node) && node.__slots !== null
? node.__slots
: EMPTY_SLOTS;
}
// @experimental named-slots. Synchronous in-lexical slot attachment: a host
// with a `$getSlotTargetElement` render-config override has the reconciler
// attach and reveal the container in the same commit that (re)mounts it —
// no listener or framework hop. A null target (the default) leaves placement
// to explicit imperative mounting (mountSlotContainer / useLexicalSlotRef).
function $applySlotTarget(
node: LexicalNode,
name: string,
hostDom: HTMLElement,
container: HTMLElement,
): void {
const target = activeEditorDOMRenderConfig.$getSlotTargetElement(
node,
name,
hostDom,
activeEditor,
);
if (target !== null) {
if (container.parentElement !== target) {
target.appendChild(container);
}
container.style.display = '';
}
}
// @experimental named-slots. A slot value's DOM is mounted directly inside its
// own `[data-lexical-slot]` container, so the container is that DOM's parent.
// Resolving by the slotted key (rather than scanning the host's direct children
// by name) survives the container being relocated out of the host — e.g. a
// decorator host that moves its slot containers into its decorate() chrome — and
// can't match a slot subtree's own nested slot container.
function $slotContainerForKey(slotKey: NodeKey): HTMLElement | null {
const slotDom = activePrevKeyToDOMMap.get(slotKey);
return slotDom !== undefined ? slotDom.parentElement : null;
}
// @experimental named-slots. Reconcile mirror of `$mountSlotChildren`.
// Slot containers already sit slots-first in the host DOM from the create
// path, so reconciling each in place keeps DOM order. Same name + key →
// reconcile in place; same name + new key → destroy old subtree, mount the
// new one into the existing container; removed name → destroy + drop its
// container; new name → mount a fresh container before the first non-slot
// child so a slot added after the host's initial render stays slots-first.
// Containers are resolved via `$slotContainerForKey` (the slotted node's DOM
// parent). Like the mount helper this leaves `subTreeTextContent` unchanged and
// returns the concatenated slot text for the caller to fold in slots-first.
function $reconcileSlotChildren(
prevNode: LexicalNode,
nextNode: LexicalNode,
hostDom: HTMLElement,
): string {
const prevSlots = $readSlots(prevNode);
const nextSlots = $readSlots(nextNode);
for (const [name, prevSlotKey] of prevSlots) {
if (!nextSlots.has(name)) {
const staleContainer = $slotContainerForKey(prevSlotKey);
$destroyNode(prevSlotKey, null);
if (staleContainer !== null) {
staleContainer.remove();
}
}
}
const previousSubTreeTextContent = subTreeTextContent;
const outerSaved = $beginCaptureGuard();
let totalText = '';
let prevContainer: Element | null = null;
const decoratorHost = $isDecoratorNode(nextNode);
for (const [name, nextSlotKey] of nextSlots) {
const prevSlotKey = prevSlots.get(name);
let container =
prevSlotKey !== undefined ? $slotContainerForKey(prevSlotKey) : null;
subTreeTextContent = '';
const saved = $beginCaptureGuard();
if (container === null) {
container = $createSlotDOM(name);
// Keep the hidden placeholder slots-first: it must land ahead of the
// linked-list children (and the terminating <br>) so the leading
// DOMSlot boundary can skip it; it must not be appended after them.
// Insert before the first non-slot child; earlier slot containers are
// skipped, so several slots added in one update preserve their Map
// order at the front.
let firstNonSlot: Element | null = null;
for (const child of hostDom.children) {
if (!child.hasAttribute('data-lexical-slot')) {
firstNonSlot = child;
break;
}
}
hostDom.insertBefore(container, firstNonSlot);
$createNode(nextSlotKey, $getDOMSlot(nextNode, container, activeEditor));
} else if (prevSlotKey === nextSlotKey) {
$reconcileNode(nextSlotKey, container);
} else {
// Reusing the container, so the old subtree's DOM must be detached
// from it (pass the container as parentDOM) before mounting the new
// one; otherwise both render side by side.
if (prevSlotKey !== undefined) {
$destroyNode(prevSlotKey, container);
}
$createNode(nextSlotKey, $getDOMSlot(nextNode, container, activeEditor));
}
$endCaptureGuard(saved);
$applySlotEditable(hostDom, decoratorHost, container);
$applySlotTarget(nextNode, name, hostDom, container);
totalText += subTreeTextContent;
// Keep placeholder DOM order in sync with the slot Map order. A reused
// container stays where it was first mounted, so a remove + re-add of an
// existing name (which moves it to the Map's tail) would otherwise leave
// its container stranded at its old DOM position, diverging from the model
// order that getSlotNames / the text fold / the exporters all read. Anchor
// each container right after the previous slot's (the first at the very
// front), staying slots-first ahead of the linked-list children. Only
// placeholders still parked in the host DOM are anchored: a container the
// host explicitly attached elsewhere (mountSlotContainer / useLexicalSlotRef)
// is owned by that mount and re-parenting it here would yank it back.
if (container.parentElement === hostDom) {
const anchor: ChildNode | null =
prevContainer === null ? hostDom.firstChild : prevContainer.nextSibling;
if (anchor !== container) {
hostDom.insertBefore(container, anchor);
}
prevContainer = container;
}
}
$endCaptureGuard(outerSaved);
subTreeTextContent = previousSubTreeTextContent;
return totalText;
}
function $createNode(key: NodeKey, slot: DOMSlot | 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.
// Two move shapes route here:
// - model move: cross-parent (linked-list children) or cross-slot-host
// (a slot value moved between hosts in one update; both nodes have
// __parent === null, so cross-host is detected via __slotHost).
// - DOM move (slot children only): a host's wrapper was recreated
// (updateDOM=true) and its slot children's existing DOM is no longer
// under the new slot container. Limited to slot children because a
// regular child whose wrapper parent was recreated should re-render
// through its type-derived createDOM (list item attributes, etc.),
// not reuse a stale wrapper.
if (slot !== null) {
const prevNode = activePrevNodeMap.get(key);
if (prevNode !== undefined) {
const existingDOM = activePrevKeyToDOMMap.get(key);
if (existingDOM !== undefined) {
const prevSlotHost = $isSlotChild(prevNode)
? prevNode.__slotHost
: null;
const nextSlotHost = $isSlotChild(node) ? node.__slotHost : null;
const modelMoved =
prevNode.__parent !== node.__parent || prevSlotHost !== nextSlotHost;
const slotChildDomDetached =
nextSlotHost !== null && existingDOM.parentElement !== slot.element;
if (modelMoved || slotChildDomDetached) {
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');
// DecoratorNode DOM is selection-captured: window selection inside
// a decorator subtree (e.g. an embedded input) is owned by the
// decorator, not by Lexical's caret management. Marking it via
// setDOMUnmanaged unifies the decorator case with extension-owned
// unmanaged subtrees so callers only need isDOMCapturingSelection /
// isDOMUnmanaged.
setDOMUnmanaged(dom, {captureSelection: true});
}
if ($isElementNode(node)) {
const indent = node.__indent;
const childrenSize = node.__size;
$setElementDirection(dom, node);
if (indent !== 0) {
setElementIndent(dom, indent);
}
// @experimental named-slots. Slots render slots-first, ahead of the
// linked-list children, each into its own container nested in the
// host DOM. Their text folds into the host's cache ahead of the
// child text to match `ElementNode.getTextContent`. The slots'
// first-text key is deliberately kept out of __lexicalFirstTextKey,
// which feeds children-only navigation / selection.
const slots = $readSlots(node);
const slotTextContent =
slots.size > 0 ? $mountSlotChildren(node, dom, slots) : '';
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 = slotTextContent;
dom.__lexicalFirstTextKey = null;
subTreeTextContent += slotTextContent;
if (slots.size > 0) {
dom.__lexicalSlotTextLength = slotTextContent.length;
}
} else {
const outerBefore = subTreeTextContent;
const endIndex = childrenSize - 1;
const children = $createChildrenArray(node, activeNextNodeMap);
$createChildren(
children,
node,
0,
endIndex,
$getDOMSlot(node, dom, activeEditor),
);
// $createChildren set dom.__lexicalTextContent to the child-only
// text and subTreeTextContent to outerBefore + childText. Rebuild
// both slots-first (slot text precedes child text) so the host's
// contribution to the parent accumulator stays in document order.
// __lexicalFirstTextKey is left as the children's — slots stay out
// of navigation / selection.
if (slotTextContent !== '') {
const childText = dom.__lexicalTextContent || '';
dom.__lexicalTextContent = slotTextContent + childText;
subTreeTextContent = outerBefore + slotTextContent + childText;
}
if (slots.size > 0) {
dom.__lexicalSlotTextLength = slotTextContent.length;
}
}
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';
// @experimental named-slots. A decorator can host editable slots; each
// mounts into its own detached contentEditable container that the
// lexical-react component relocates into the decorate() chrome. The slot
// text is already folded into `text` by getTextContent
// ($getSlotsTextContent), so this mount is render-only —
// $mountSlotChildren preserves subTreeTextContent.
const slots = $readSlots(node);
if (slots.size > 0) {
$mountSlotChildren(node, dom, slots);
}
}
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: 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;
}
}
// A host with slots but no linked-list children is not empty (the slots
// carry its content). The 'empty' line break exists to give a truly empty
// block a caret target; on a slots-only host that <br> would instead be a
// stray caret target in the host's own child area, after the slot
// containers — text typed there leaks out of the slot. Skip it.
return $readSlots(element).size > 0 ? null : '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;
}
}
// @experimental named-slots. `cachedParentText` holds the host's combined
// cache (slot text folded slots-first ahead of the child text). The suffix
// we just rebuilt is child-only, so strip the slot prefix to recover the
// child-only cache before splicing, and write child-only here — the slot
// fold in `$reconcileNode` re-prepends the slot text. `slotLen` is `0` for
// non-slot hosts, so the slice is a no-op and they splice unchanged.
const slotLen = cacheDom.__lexicalSlotTextLength || 0;
const prevChildText =
slotLen > 0 ? cachedParentText.slice(slotLen) : cachedParentText;
cacheDom.__lexicalTextContent =
prevChildText.slice(0, prevChildText.length - oldSuffixLength) + newSuffix;
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.