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.
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; }});
// Root is always intentionally dirty if any attached node is dirtyconst 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.
// All transforms for a node type run in registration ordereditor.registerNodeTransform(MyNode, transform1);editor.registerNodeTransform(MyNode, transform2);// transform1 runs, then transform2
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.
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'); }});
editor.registerNodeTransform(MyNode, (node) => { // Always check if node is still attached if (!node.isAttached()) return; // Check other conditions if (node.isEmpty()) { node.remove(); }});
Avoid Unconditional Mutations
// Bad - infinite loopeditor.registerNodeTransform(TextNode, (node) => { node.getWritable(); // Always marks dirty!});// Good - conditionaleditor.registerNodeTransform(TextNode, (node) => { if (needsUpdate(node)) { node.getWritable().setFormat('bold'); }});
Use Transforms for Invariants
// Enforce that headings can't be emptyeditor.registerNodeTransform(HeadingNode, (node) => { if (node.getChildrenSize() === 0) { node.append($createTextNode(' ')); }});
Consider Performance
// Bad - expensive on every keystrokeeditor.registerNodeTransform(TextNode, (node) => { const text = node.getTextContent(); // Complex regex on every text change if (/some-complex-pattern/.test(text)) { // ... }});// Good - optimize checkseditor.registerNodeTransform(TextNode, (node) => { // Quick bailout if (!node.getTextContent().includes('@')) return; // Expensive check only when needed if (/some-complex-pattern/.test(node.getTextContent())) { // ... }});