Skip to main content

Overview

Node transforms are functions that automatically run when nodes of a specific type are created or modified. They’re essential for enforcing invariants, normalizing content, and implementing advanced editor behaviors.
Transforms run after your update function but before DOM reconciliation, giving you a chance to normalize the editor state.

Transform Basics

Registering Transforms

const removeTransform = editor.registerNodeTransform(
  ParagraphNode,
  (node: ParagraphNode) => {
    // Transform logic
    if (node.isEmpty()) {
      node.remove();
    }
  }
);

// Cleanup when done
removeTransform();
Transforms have implicit update context - you can call $ functions directly without wrapping in editor.update().

Transform Execution Flow

Transform Heuristic

From LexicalUpdates.ts:244-252:
// 1. Transform leaves first
//    If transforms generate dirty nodes → repeat step 1
//    Marking a leaf dirty also marks parents dirty

// 2. Transform elements
//    If transforms generate dirty nodes → repeat step 1  
//    If only dirty elements → repeat step 2

Advanced Transform Patterns

1. Enforcing Invariants

// Ensure list items always have bullet or number
editor.registerNodeTransform(ListItemNode, (node) => {
  if (!node.getChildren().some(child => 
    child.getType() === 'listitem-content'
  )) {
    // Add required structure
    node.append($createListItemContentNode());
  }
});

2. Auto-Linking

const URL_REGEX = /https?:\/\/[^\s]+/g;

editor.registerNodeTransform(TextNode, (node) => {
  // Skip if already in a link
  if (node.hasFormat('code') || node.getParent()?.getType() === 'link') {
    return;
  }
  
  const text = node.getTextContent();
  const matches = Array.from(text.matchAll(URL_REGEX));
  
  if (matches.length === 0) return;
  
  // Split text node and wrap URLs in links
  let currentNode = node;
  
  for (const match of matches) {
    const [url] = match;
    const index = match.index!;
    
    // Split at URL start
    const [before, urlNode] = currentNode.splitText(index);
    // Split at URL end  
    const [, after] = urlNode.splitText(url.length);
    
    // Wrap in link
    const linkNode = $createLinkNode(url);
    urlNode.replace(linkNode);
    linkNode.append(urlNode);
    
    currentNode = after;
  }
});

3. Text Normalization

Lexical automatically normalizes text nodes:
export function $normalizeTextNode(textNode: TextNode): void {
  let node = textNode;
  
  // Remove empty text nodes
  if (node.__text === '' && node.isSimpleText() && !node.isUnmergeable()) {
    node.remove();
    return;
  }
  
  // Merge with previous sibling if possible
  let previousNode;
  while (
    (previousNode = node.getPreviousSibling()) !== null &&
    $isTextNode(previousNode) &&
    previousNode.isSimpleText() &&
    !previousNode.isUnmergeable()
  ) {
    if (previousNode.__text === '') {
      previousNode.remove();
    } else if ($canSimpleTextNodesBeMerged(previousNode, node)) {
      node = $mergeTextNodes(previousNode, node);
      break;
    } else {
      break;
    }
  }
  
  // Same for next sibling...
}
Text nodes are automatically merged when they have the same format, mode, and style. Mark a node as “unmergeable” to prevent this.

4. Composition Handling

function $isNodeValidForTransform(
  node: LexicalNode,
  compositionKey: string | null
): boolean {
  return (
    node !== undefined &&
    // Don't transform nodes being composed (IME input)
    node.__key !== compositionKey &&
    node.isAttached()
  );
}

Transform Priority

Root Transform

The root node is always transformed last:
// Root is always intentionally dirty if any attached node is dirty
const rootDirty = untransformedDirtyElements.delete('root');
if (rootDirty) {
  // Re-insert at end
  untransformedDirtyElements.set('root', true);
}
Use root transforms as a “finalization” step - they run after all other transforms.

Multiple Transforms per Node

// All transforms for a node type run in registration order
editor.registerNodeTransform(MyNode, transform1);
editor.registerNodeTransform(MyNode, transform2);

// transform1 runs, then transform2

Infinite Transform Detection

Lexical protects against infinite transform loops:
export function errorOnInfiniteTransforms(): void {
  if (infiniteTransformCount > 99) {
    invariant(
      false,
      'One or more transforms are endlessly triggering additional transforms.'
    );
  }
}
If you see this error, check that your transforms have proper exit conditions and aren’t unconditionally marking nodes dirty.

Common Transform Use Cases

Auto-Formatting

editor.registerNodeTransform(TextNode, (node) => {
  const text = node.getTextContent();
  
  // Bold text between **asterisks**
  if (/^\*\*(.+)\*\*$/.test(text)) {
    node.setTextContent(text.slice(2, -2));
    node.toggleFormat('bold');
  }
  
  // Italic text between *single asterisks*
  if (/^\*([^*]+)\*$/.test(text)) {
    node.setTextContent(text.slice(1, -1));
    node.toggleFormat('italic');
  }
});

Mention Nodes

const MENTION_REGEX = /@([\w]+)/g;

editor.registerNodeTransform(TextNode, (node) => {
  const text = node.getTextContent();
  const matches = Array.from(text.matchAll(MENTION_REGEX));
  
  if (matches.length === 0) return;
  
  let currentNode = node;
  
  for (const match of matches) {
    const [fullMatch, username] = match;
    const index = match.index!;
    
    const [, mentionText] = currentNode.splitText(index);
    const [mentionNode, after] = mentionText.splitText(fullMatch.length);
    
    // Replace with custom mention node
    const mention = $createMentionNode(username);
    mentionNode.replace(mention);
    
    currentNode = after;
  }
});

Maintaining Structure

editor.registerNodeTransform(TableNode, (table) => {
  // Ensure all rows have same column count
  const rows = table.getChildren();
  if (rows.length === 0) return;
  
  const columnCount = rows[0].getChildrenSize();
  
  for (const row of rows) {
    const cells = row.getChildren();
    const diff = columnCount - cells.length;
    
    if (diff > 0) {
      // Add missing cells
      for (let i = 0; i < diff; i++) {
        row.append($createTableCellNode());
      }
    } else if (diff < 0) {
      // Remove extra cells
      cells.slice(columnCount).forEach(cell => cell.remove());
    }
  }
});

Transform vs. Command

Use Transforms When

  • Enforcing node constraints
  • Auto-formatting content
  • Normalizing structure
  • Reacting to any node change

Use Commands When

  • Handling user actions
  • Coordinating across plugins
  • Enabling/disabling features
  • Priority-based handling

Normalized Nodes Tracking

function $mergeTextNodes(node1: TextNode, node2: TextNode): TextNode {
  const writableNode1 = node1.mergeWithSibling(node2);
  
  // Track that these nodes were normalized
  const normalizedNodes = getActiveEditor()._normalizedNodes;
  normalizedNodes.add(node1.__key);
  normalizedNodes.add(node2.__key);
  
  return writableNode1;
}
Normalized nodes are reported in update listeners alongside mutated nodes.

Debugging Transforms

Log Transform Execution

editor.registerNodeTransform(MyNode, (node) => {
  console.log('Transform triggered for:', node.getKey());
  console.log('Node attached:', node.isAttached());
  console.log('Node dirty:', node.isDirty());
  
  // Your transform logic
});

Track Transform Count

let transformCount = 0;

editor.registerUpdateListener(({ editorState, tags }) => {
  transformCount++;
  console.log(`Update #${transformCount}`);
  console.log('Tags:', tags);
});

Best Practices

editor.registerNodeTransform(MyNode, (node) => {
  // Always check if node is still attached
  if (!node.isAttached()) return;
  
  // Check other conditions
  if (node.isEmpty()) {
    node.remove();
  }
});
// Bad - infinite loop
editor.registerNodeTransform(TextNode, (node) => {
  node.getWritable(); // Always marks dirty!
});

// Good - conditional
editor.registerNodeTransform(TextNode, (node) => {
  if (needsUpdate(node)) {
    node.getWritable().setFormat('bold');
  }
});
// Enforce that headings can't be empty
editor.registerNodeTransform(HeadingNode, (node) => {
  if (node.getChildrenSize() === 0) {
    node.append($createTextNode(' '));
  }
});
// Bad - expensive on every keystroke
editor.registerNodeTransform(TextNode, (node) => {
  const text = node.getTextContent();
  // Complex regex on every text change
  if (/some-complex-pattern/.test(text)) {
    // ...
  }
});

// Good - optimize checks
editor.registerNodeTransform(TextNode, (node) => {
  // Quick bailout
  if (!node.getTextContent().includes('@')) return;
  
  // Expensive check only when needed
  if (/some-complex-pattern/.test(node.getTextContent())) {
    // ...
  }
});

Static Transform Method

Nodes can register transforms via static method:
class AutoLinkNode extends TextNode {
  static transform(): ((node: AutoLinkNode) => void) | null {
    return (node: AutoLinkNode) => {
      // Transform logic here
      const text = node.getTextContent();
      if (isURL(text)) {
        const link = $createLinkNode(text);
        node.replace(link);
        link.append(node);
      }
    };
  }
}
The static transform() method is experimental. Prefer editor.registerNodeTransform() for production code.

Reconciliation

Understand how transforms feed into reconciliation

Commands

Compare transforms with the command system

Custom Nodes

Build nodes that work well with transforms

Performance

Optimize transform performance

Build docs developers (and LLMs) love