Technical Documentation: facebook/lexical
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
Features
- Framework Agnostic Core - Works with any UI framework, with official React bindings
- Reliable & Accessible - Built-in accessibility support and WCAG compliance
- Extensible - Plugin-based architecture with powerful extension points
- Immutable State Model - Time-travel ready with built-in undo/redo
- Collaborative Editing - Real-time collaboration via Yjs integration
- Serialization - Import/export from JSON, Markdown, and HTML
- Rich Content - Support for tables, lists, code blocks, images, and custom nodes
- Cross-browser - Firefox 115+, Safari 15+, Chrome 86+ (see Supported Browsers)
- Type Safe - Written in TypeScript with comprehensive type definitions
Quick Start
For framework-agnostic usage, start with the
Vanilla JS quick start
or the
Vanilla JS example.
For React applications, install Lexical with the official React bindings:
npm install lexical @lexical/reactimport { $getRoot, $getSelection } from 'lexical';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
const initialConfig = {
namespace: 'MyEditor',
onError: (error) => console.error(error),
};
function Editor() {
return (
<LexicalComposer initialConfig={initialConfig}>
<PlainTextPlugin
contentEditable={<ContentEditable />}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
</LexicalComposer>
);
}Try it yourself:
Development
# Install dependencies
pnpm install
# Start playground dev server
pnpm run start
# Run tests
pnpm run test-unit
pnpm run test-e2e-chromium
# Lint and type check
pnpm run ci-checkSee CONTRIBUTING.md for detailed development guidelines.
Documentation
- User Guide: lexical.dev/docs/intro
- API Reference: lexical.dev/docs/api
- Developer Guide: AGENTS.md - Architecture and development workflows
- Examples: examples/ - Sample implementations
Community & Support
- Discord: Join our Discord server for announcements, questions, discussions
- Issues: Report bugs and request features on GitHub Issues
- Release Notes: Release notes are available in Github Releases
Browser Support
| Browser | Version |
|---|---|
| Chrome | 86+ |
| Firefox | 115+ |
| Safari | 15+ |
| Edge | 86+ |
Contributors
We welcome contributions! Please read our Contributing Guide to learn about our development process and how to propose bugfixes and improvements.
License
MIT License Copyright ©2026 Meta Platforms, Inc.
Overview (`dev-examples/dom-import/README.md`)
DOMImportExtension example
A reduced rich-text editor focused on demonstrating the newDOMImportExtension import pipeline. Each node-providing extension
registers its own import rules, so listing the runtime extensions is
all the configuration the pipeline needs —ClipboardDOMImportExtension is the only import-specific dependency:
- Rich text — paragraphs, headings, quotes (
RichTextExtension,
which also registers the<h1>–<h6>/ `` import
rules and the sharedCoreImportExtensionbaseline). - Lists — bullet / numbered / check-list (
ListExtension). - Tables (
TableExtension). - Links (
LinkExtension). - Horizontal rules (
HorizontalRuleExtension— the<hr>rule
ships withCoreImportExtension, gated on the node being
registered). - Code blocks with Shiki highlighting (
CodeShikiExtension
registersCodeNode/CodeHighlightNodeand wires Shiki up as
the syntax-highlighting tokenizer; the underlyingCodeExtension
brings the import rules —, multi-line `<code>`, GitHub raw-file-view code tables, monospace, and the VS Code
paste consolidation preprocess). - Markdown shortcuts — type
#,*,1.,>,```
/* Detailed source-code truncated for AI context efficiency. */
pnpm run start:dev-example dom-import(or pnpm -F @lexical/dev-dom-import-example dev directly.)
Overview (`dev-examples/hmr/README.md`)
Lexical HMR Example
A minimal Vite + React app that demonstratesHMRExtension from @lexical/extension. Editor content, the editable flag, and undo/redo history (viaHistoryExtension) are preserved across hot module reloads.
Running
From the repository root:
pnpm install
pnpm -C dev-examples/hmr devThen open the printed URL, type something, and edit src/App.tsx. The editor
state survives the reload.
Overview (`dev-examples/mdast-editor/README.md`)
Mdast Editor (dev example)
A WYSIWYG Markdown editor with an editable Markdown source pane
(synchronized in both directions, exercising import and export), built on
the unreleased@lexical/mdast package — the
micromark/mdast-based alternative to @lexical/markdown. It is a port ofexamples/markdown-editor, which is the
same app built on the legacy bespoke implementation; comparing the two shows
what the extension-first design removes.
pnpm run start:dev-example mdast-editorBecause this example depends on unreleased workspace packages, the
StackBlitz link opens the monorepo root and boots the example with thestart:dev-example:mdast-editor script.
Deployed builds of every dev example are served from the lexical
website at /dev-examples/<name>/ (this one at
/dev-examples/mdast-editor/),
including on every Cloudflare/Vercel deploy preview. To build them
locally: pnpm run build && pnpm run build:dev-examples at the
monorepo root, which outputs intopackages/lexical-website/static/dev-examples/. These are built from
the development artifacts on purpose — they are demos, and the
development builds keep Lexical's full error messages.
Repro links
The document can be seeded from the URL hash (mirroring the playground's#doc= convention), which makes bug reports reproducible with a plain
link. Two encodings, so a repro can use whichever fits:
#md=<encodeURIComponent(markdown)>— the Markdown source, hand-editable
directly in the URL:/dev-examples/mdast-editor/#md=%23%20Repro%0A%0A*hello*#doc=<base64url(gzip(SerializedDocument))>— the full editor state
JSON (via@lexical/file), for node/state shapes Markdown can't
express.
The Share MD / Share JSON buttons in the Markdown pane header put
the current document in the URL (and on the clipboard) in each encoding.
While a hash document is shown, localStorage persistence is suspended so a
repro link never clobbers locally persisted work; Reset drops the hash
and resumes normal persistence.
What got simpler vs. `examples/markdown-editor`
The legacy example's MarkdownExtension.ts is ~240 lines; the equivalentMdastEditorExtension.ts here is
example plumbing only (the preview signal and two toolbar commands), because@lexical/mdast ships extension-ready:
- No transformer list to curate. Depending on
MdastShortcutsExtension
pulls in the CommonMark + GFM grammar and every node it needs. - No
registerMarkdownShortcutscall. The extension registers the
streaming shortcuts itself, driven by the same micromark grammar as
document import. - No checklist workaround. The legacy example needed a custom
text-matchtransformer (CHECK_LIST_ITEM, ~50 lines) so[ ]typed in
an existing bullet item would convert; that behavior is built in. - No newline-handling configuration.
shouldPreserveNewlines/mergeAdjacentNewlinesdon't exist; micromark parses CommonMark as-is
and the original syntax is preserved on the nodes viaNodeState.
It also does more: blockquotes, links, strikethrough, thematic breaks, and
fenced code blocks round-trip out of the box (the legacy example's default
set covered headings, lists, and inline formatting), and the code blocks
are syntax-highlighted by adding CodeShikiExtension — highlighting
composes with the Markdown machinery without any extra wiring.
Bundle size
The flip side of parsing with micromark is bundle weight. Build the
production dist artifacts first, then run the comparison:
pnpm run build-prod # at the monorepo root
npm run size # in this directoryIt builds functionally-equivalent headless bundles — editor + markdown
node set + typing shortcuts + import/export — one per implementation, from
the production dist artifacts, and prints their sizes. All include the
lexical core, so the deltas are the markdown machinery itself. Snapshot at
the time of writing:
| bundle | minified | min+gzip |
|---|---|---|
legacy @lexical/markdown |
287.3 kB | 77.3 kB |
@lexical/mdast |
408.6 kB | 103.6 kB |
| delta (full vs legacy) | +121.4 kB | +26.3 kB |
The dist build keeps the micromark/mdast dependencies external, so the app
bundler resolves them with browser export conditions and tree-shakes what's
unused.
Overview (`examples/agent-example/README.md`)
AI Agent Example
A React + Tailwind CSS rich text editor with AI agent functionality powered by transformers.js. The AI runs entirely in the browser using WebAssembly — no server required.
Based on the website-toolbar example with added AI capabilities.
Features
- Rich text editing with block types (headings, quotes), formatting (bold, italic, underline), and alignment
- AI Generate — click "Generate" to have the AI write a new paragraph, streamed token-by-token into the editor
- Extract Entities — detect people, places, and organizations in the text and replace them with interactive decorator nodes (PlaceNode links to Google Maps, PersonNode and OrgNode link to Google search)
- Abort — click "Stop" or press Escape to cancel an in-progress AI operation
- Dark mode toggle
How it works
Uses two models running via @huggingface/transformers in a Web Worker with the WASM backend for broad compatibility, including Safari on iOS:
- SmolLM2-135M-Instruct (q4 quantized, ~70MB) for text generation
- Xenova/bert-base-NER for named entity recognition (people, places, organizations)
Models are loaded lazily on first use. Generated text streams directly into the editor as tokens arrive. Extracted entities are converted into color-coded decorator nodes with contextual links.
Note: SmolLM2-135M is a small language model. Results are best-effort and meant as a demonstration of browser-based AI integration with Lexical.
Run it locally
pnpm i && pnpm run devOverview (`examples/extension-react-table/README.md`)
React Table Extension example
Here we have simplest Lexical Table Plugin setup in rich text configuration (@lexical/rich-text) with history (@lexical/history) and accessibility (@lexical/dragon) features enabled, built with Lexical Extension.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/extension-sveltekit-ssr-hydration/README.md`)
Svelte 5 + Lexical Extension Demo
This example demonstrates how you can use Lexical Extension signals
reactively with a $ prefix because they comply with the Svelte Store API.
This example shows how you might do SSR to prerender the editor's HTML
and hydrate it client-side directly from the prerendered HTML (so you're not
sending the content twice). Note that this only works well when your importDOM
is lossless relative to the createDOM output.
It's also a rudimentary demo for how you can support Vite HMR to preserve
the editor's state during reloads (great for tweaking html/styles/etc.)
Overview (`examples/extension-vanilla-react-plugin-host/README.md`)
Vanilla JS Extension with a React plug-in host
Demonstrates using Lexical Extension to build a very basic checklist editor with Vanilla JS, using Tailwind for CSS, and rendering a React-based extension (TreeViewExtension) into the page with ReactPluginHostExtension.
- The editor is built with
buildEditorFromExtensions, fromRichTextExtension,HistoryExtension,CheckListExtensionand others. ReactPluginHostExtensionowns a React root.mountReactPluginHostmounts it, andmountReactExtensionComponentrenders the tree view into#tree-view, so the app needs React at runtime but no JSX.configExtension(TreeViewExtension, {...})styles the tree view with Tailwind classes.TailwindExtensionfrom@lexical/tailwindsupplies the editor theme.src/styles.csspoints Tailwind at that package with@sourceso the classes it uses are generated.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/extension-vanilla-tailwind/README.md`)
Basic Vanilla JS Extension with Tailwind example
Demonstrates using Lexical Extension to build a very basic checklist editor with Vanilla JS (no React), using Tailwind for CSS.
- The editor is built with
buildEditorFromExtensions, fromRichTextExtension,HistoryExtension,CheckListExtensionand others. TailwindExtensionfrom@lexical/tailwindsupplies a theme of Tailwind classes.src/styles.csspoints Tailwind at that package with@sourceso the classes it uses are generated.- The "Editor state" panel is kept in sync by a small extension that reads the
EditorStateExtensionsignal. LazyExtensionshows how an extension can load more code with a dynamicimport()and still clean up when the editor is disposed.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/markdown-editor/README.md`)
Markdown Editor example
This example demonstrates a split-view markdown editor: a WYSIWYG editor on
the left synchronized with a live markdown preview on the right. It is built
entirely on the Lexical Extension
system — every piece of state and behavior the React UI relies on is provided
by an extension as a command, an output signal, or both.
Layout
src/extensions/MarkdownExtension.ts— pulls inRichTextExtension,HistoryExtension,ListExtension,CheckListExtension,TabIndentationExtensionandEditorStateExtension; registersregisterMarkdownShortcutswith the curatedMARKDOWN_TRANSFORMERS
array (headings, ordered/unordered/check lists, inline code, bold,
italics, bold-italic) so that typing#,**bold**,`code`,- item,1. item,[ ] todoetc. transforms inline. The
transformer list keepsCHECK_LISTahead ofUNORDERED_LISTso that- [ ] foomatches the checklist regex first on import. A customCHECK_LIST_ITEMTextMatchTransformer(trigger' ') handles the
typing-time case where the user has already typed-(turning the
line into a bullet list item) and then types[ ]— the standard
element transformer can't fire there because its parent is no longer
a paragraph at root, but the text-match transformer has no such
restriction. The extension also registersFORMAT_PARAGRAPH_COMMAND
andFORMAT_HEADING_COMMAND<HeadingTagType>, and exposes the
current document as amarkdowncomputedsignal derived offEditorStateExtension.src/extensions/MarkdownPersistenceExtension.ts— owns the
localStorage ↔ editor sync. It provides the editor's$initialEditorStateso the document is seeded fromlocalStorage[storageKey](falling back to the configureddefaultMarkdown). An effect onMarkdownExtension'smarkdown
signal writes back on every change, andRESET_MARKDOWN_COMMAND
clears the storage entry and re-imports the default in place — no
page reload.src/extensions/ToolbarStateExtension.ts— exposes everything
the toolbar reads as output signals:canUndo/canRedoarecomputedoffHistoryExtension'shistoryState, whileblockType,isBold,isItalic, andisCodearecomputedoff
the editor-state signal. The React component callsuseExtensionSignalValuefor each signal and dispatches commands —
no localuseState/useEffectand no second update listener.src/plugins/ToolbarPlugin.tsx— block-type select (paragraph,
heading 1-3, bullet/numbered/check list), undo / redo, and bold /
italic / inline-code buttons. Pure UI: dispatches commands, reads
signals.src/plugins/MarkdownPreviewPlugin.tsx— one-liner that reads
the markdown signal viauseExtensionSignalValueand renders it.src/Editor.tsx— composes the extensions throughLexicalExtensionComposer, passing the storage key and demo
document asMarkdownPersistenceExtensionconfig.
Tests
pnpm run test runs vitest. The suite covers:
- Round-tripping every supported feature through the markdown
transformers ($convertFromMarkdownString→$convertToMarkdownString). - That
CHECK_LISTmatches- [x] fooahead ofUNORDERED_LISTon
import, and that the newCHECK_LIST_ITEMtext-match transformer
flips a bullet list into a checklist when the user types[ ]/[x]inside an existing list item (simulated character-by-character
viaselection.insertText). - That
MarkdownExtension'smarkdownoutput signal updates as the
editor state changes, and thatToolbarStateExtension'scanUndo/canRedo/blockType/isBold/isItalic/isCodesignals
follow real edits, realUNDO_COMMAND/REDO_COMMAND/FORMAT_TEXT_COMMANDdispatches, and selection changes.
Running
pnpm install
pnpm run dev # vite dev server
pnpm run build # production build
pnpm run typecheck # tsc --noEmit
pnpm run test # vitest runOverview (`examples/nextjs-code-shiki/README.md`)
Next.js + `@lexical/code-shiki` example
A minimal Next.js app that drives a Lexical rich-text
editor with Shiki-based code highlighting, wired entirely through the
Lexical Extension system.
What it demonstrates
- Using
LexicalExtensionComposer(from@lexical/react/LexicalExtensionComposer)
withRichTextExtension,HistoryExtension,AutoFocusExtension, and a
small example-ownedCodeShikiDemoExtensionthat pulls inCodeShikiExtensionas a dependency. No legacyLexicalComposer/*Pluginwrappers. - Driving
@lexical/code-shiki's public API from the extension:getCodeLanguageOptions()to seed the editor with the language list
that shiki has bundled metadata for, via the extension's$initialEditorStatehook.loadCodeLanguage('python')from the extension'sregisterhook to
exercise shiki's dynamic@shikijs/langs/<lang>import path through
Next.js' bundler. Once the promise resolves andisCodeLanguageLoaded
confirms it, the extension appendsLoaded: pythonto the document.
Why this example exists
This is also the release-artifact integration test for @lexical/code-shiki.scripts/__tests__/integration/prepare-release.test.mjs globsexamples/*/package.json, installs each one against the freshly builtnpm/*.tgz tarballs, runs npm run build (so Next.js bundles the example
against the published @lexical/code-shiki), then runs npm run test
(Playwright) against the production build. The Playwright assertions
confirm:
Registered:.*typescript— the synchronousbundledLanguagesInfolist
resolved through the published bundle.Loaded: python— the dynamicimport('@shikijs/langs/python')
inside shiki resolved at runtime, which only works ifshiki/@shikijs/*are external in the published@lexical/code-shikibundle rather than inlined by Rollup.
Local development
pnpm install
pnpm run dev # next dev on http://localhost:3000
pnpm run build # next build
pnpm run start # next start (used by playwright.config.ts)
pnpm run test # playwright tests against the built appOverview (`examples/node-replacement/README.md`)
Node Replacement Example
A rich text editor built with extensions (RichTextExtension, HistoryExtension, and AutoFocusExtension) that uses node replacement to swap every ParagraphNode for a CustomParagraphNode. The replacement is declared in the nodes of CustomParagraphExtension.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/node-state-review/README.md`)
Paragraph review with NodeState
This example adds a reviewed flag to existing paragraph nodes. DOMRenderExtension renders and exports the flag as data-reviewed="true"; DOMImportExtension restores it from HTML. NodeState handles JSON serialization and cloning without a custom node class.
Run npm install and npm run dev to start the standalone example. From the Lexical repository, run pnpm build and pnpm exec vite examples/node-state-review -c examples/node-state-review/vite.config.monorepo.ts to use the local packages.
Place the cursor in a paragraph and choose Toggle reviewed. Use Export HTML, edit the HTML, then choose Import HTML to try the round trip. Expand Editor state JSON to see the stored data.
Overview (`examples/node-state-style/README.md`)
Node State Style example
Here we have an example that demonstrates how NodeState can be used with a
DOMRenderExtension to override the create and export behavior of any node, and a
DOMImportExtension to capture arbitrary inline style properties when importing
HTML.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/react-mathtype/README.md`)
React MathType example
This example demonstrates a standalone WIRIS MathType integration for a
Lexical React editor. It keeps MathType as an example dependency and stores
formulas in a custom Lexical node instead of allowing MathType to mutate the
Lexical contenteditable directly.
The bridge is packaged as MathTypeExtension. It uses@wiris/mathtype-generic to open the MathType/ChemType UI, converts the
generated MathML image into MathTypeNode state inside editor.update, and
reopens MathType when a formula node is double-clicked.
MathType stores formulas as MathML. The rendered formula image is kept in node
state for the demo, while DOM export emits the same img.Wirisformula shape
that MathType's parser expects.
How the pieces fit together
MathTypeExtension.build owns the mutable bridge state — the MathType
integration instance and the node the open dialog is editing — for the
lifetime of one editor, and exposes two functions over it:
mountIntegration(target, toolbar)creates the MathType integration and
returns its teardown function.MathTypeIntegrationComponentcalls it from
an effect and renders the toolbar plus the offscreen element MathType uses
to anchor its dialog.editFormula(nodeKey, formula)reopens MathType for an existing node. TheMathTypeNodedecorator reaches it withuseExtensionDependency(MathTypeExtension).
Using the extension's own output rather than a React context means the
extension does not have to override ReactExtension'sEditorChildrenComponent, which is a single, last-one-wins slot: taking it
would silently break any other extension in the editor that needs it.
Running
pnpm install
pnpm run dev
pnpm run build
pnpm run typecheckMathType may require a WIRIS license or self-hosted services for production
use. See the MathType generic integration documentation for service
configuration details. Out of the box the integration calls WIRIS' hosted demo
services at www.wiris.net, so the editor needs network access to them to
render formulas.
@wiris/mathtype-generic is imported eagerly at module scope, because it
installs the window.WirisPlugin singleton that MathTypeNode needs in order
to render a formula on the first paint. That puts the whole MathType bundle in
the initial chunk, which is why vite build reports a chunk over 500 kB.
Overview (`examples/react-plain-text/README.md`)
React Plain Text Lexical example
A plain text editor configured with one root extension. It composes PlainTextExtension and HistoryExtension with LexicalExtensionComposer. The debug panel uses TreeViewExtension.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/react-rich-collab/README.md`)
Basic Vanilla JS example
Here we have simplest Lexical collaboration mode setup. Use it as a starting point for your own projects as well as platform for bug reporting!
Run it locally: pnpm i && pnpm run dev:local
Overview (`examples/react-rich/README.md`)
React Rich Text example
A rich text editor configured with one root extension. It composes RichTextExtension and HistoryExtension with LexicalExtensionComposer. The debug panel uses TreeViewExtension.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/react-table/README.md`)
React Table Plugin example
Here we have simplest Lexical Table Plugin setup in rich text configuration (@lexical/rich-text) with history (@lexical/history) and accessibility (@lexical/dragon) features enabled.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/shadow-dom-web-component/README.md`)
Lexical Web Component (Shadow DOM) Example
A framework-free Vite app that packages a Lexical
rich-text editor as a custom element (<lexical-editor>) whose toolbar,
styles, and contentEditable all live inside an open ShadowRoot —
the scenario from
facebook/lexical#2119,
#6709, and
#8125.
Where the sibling shadow-dom example demonstrates a React
app with the editor in a shadow root and the toolbar outside it, this one
demonstrates the inverse packaging: a fully self-contained web component.
The demo page mounts four instances — three light-DOM <lexical-editor>
hosts inside the form (a required notes editor, a themable summary editor,
and a pre-rendered editor that hydrates from <template shadowrootmode="open">)
plus a fourth instance inside a wrapper <div> that opens its own shadow root,
so the editor's contentEditable sits two shadow boundaries below the document
and exercises the multi-level walk through getDOMShadowRoots. Everything
runs on platform APIs only:
Element.attachShadow({mode: 'open'})inconnectedCallback, with the
editor built by@lexical/extension'sbuildEditorFromExtensionsand torn
down (editor.dispose()) indisconnectedCallback.- Form association through the standard
ElementInternals
API — each editor submits its serialized editor state with the surrounding<form>, no hidden<input>required. requiredvalidation throughElementInternals.setValidity,
driven by the editor's plain text content. A<lexical-editor required>
participates inform.checkValidity()and the browser's native invalid-form
UI just like a<textarea required>.disabledandreadonlyattributes that flip Lexical's editable
state.disabledalso drops the editor out ofFormDataand skips
validation, matching<input disabled>; theformDisabledCallback
picks up an ancestor<fieldset disabled>automatically.- CSS custom property theming — the editor exposes
--lexical-bg,--lexical-fg, and a small palette of toolbar variables on its host
element. The page redefines them to recolour the editor; inherited
custom properties cross the shadow boundary on their own, so the
internal layout stays private. - Toolbar slot —
<button slot="toolbar-extra">projects a
light-DOM button into the editor's toolbar row. The button stays in
the page (its click never crosses the boundary), but the page can drive
the editor through the host's public API. - Floating selection popover — the editor emits a composed
lexical-selection-rectCustomEventcarrying the live viewport
rect of the selection inside its shadow root, computed through
Lexical'sgetDOMSelectionRangeAndPoints. A page-level
popover
positions itself from those coordinates and drives bold / italic /
underline back through the host's editor. - A composed
inputevent that crosses the shadow boundary so the page can
observe edits. - Selection inside the shadow root is resolved by Lexical itself via
Selection.getComposedRanges/Selection.direction, and focus viaShadowRoot.activeElement.
Running
pnpm i && pnpm run devTo run it against the Lexical sources in this repository instead of the
published packages, use pnpm run monorepo:dev.
Then open the printed URL. Try:
- Typing in any of the four editors and switching between them (each keeps
its own selection and history). - Selecting words with
Alt/Ctrl+Shift+ arrow keys, then using the
in-shadow toolbar buttons — they reflect the selection's formats. - Word/line deletion with
Alt/Ctrl+Backspace/Delete. - Submitting the form to see each editor's serialized state in the output.
- Submitting the form while the first editor (
required) is empty: the
browser blocks the submit and surfaces its native validation tooltip on
the editor. - Clicking the Clear button next to the notes toolbar — that button
lives in the light DOM and is projected through thetoolbar-extra
slot, then drives the editor through the host's public API. - Toggling the Lock the summary editor checkbox to flip the
readonlyattribute on the summary editor, then trying to type
inside it: the contentEditable refuses input, but the form still
submits the value. - Selecting a few words in either editor — the floating B / I / U
popover appears anchored under the selection (in the page, not the
shadow root), and clicking it formats the text through the editor. - Switching the OS or browser between light and dark mode (or
redefining--lexical-bg,--lexical-fg, etc. in the page CSS) to
see how page-side CSS variables retheme each editor without touching
its shadow root.
Tests
Playwright tests in tests/ cover the
editors rendering in independent shadow roots (including the nested
editor inside the wrapper shadow root), typing and formatting, editor
independence, word deletion, ElementInternals form association, the
composed input event crossing the shadow boundary, and the floating
popover anchoring to a selection inside the nested shadow root. They
start the dev server automatically:
pnpm run testWhat's covered
This example aims to be a full reference a production user can copy out.
The Playwright suite covers each of the surfaces above plus a second
round of audit items:
- DOM-move state round-trip (
disconnectedCallbackcaches the
serialized state,connectedCallbackrestores it) delegatesFocus: trueon the shadow root +tabindex="0"on the
contentEditablehost.setCustomValidity()+ the standardvalidity/willValidate
/checkValidity/reportValiditysurfaceformAssociatedCallback+host.form,formResetCallback,formDisabledCallback,formStateRestoreCallback(bfcache / autocomplete)- The standard
inertattribute,aria-label/aria-invalid/role="textbox"mirroring - A composed
lexical-validity-changeevent for a visible error
message @media (prefers-color-scheme: dark)/(prefers-reduced-motion)/(forced-colors: active)inside the shadow stylesheet- Declarative shadow DOM (
<template shadowrootmode>) — the third
editor on the demo page pre-renders its shadow content and ourconnectedCallbackreuses the existing.contentelement instead
of creating a fresh contentEditable - Hardened lifecycle: a duplicate
customElements.defineof the
same tag throwsNotSupportedError(the shipped helper guards
against this); a host that fails to build doesn't crash the
surrounding page
The playground e2e suite (packages/lexical-playground/__tests__/e2e/ShadowDOM.spec.mjs)
covers the corresponding playground-side surfaces: markdown shortcuts
(# heading, - list) and @lexical/list inside the shadow root,@lexical/history undo/redo, the tree-view mirror, pointer events
(composedPath recovery for touch / pen / mouse), HTML paste
sanitization (a <script> tag is stripped), a large keyboard input
that keeps the reconciler responsive, image insert + paste,
NodeSelection on image click, blur + re-focus through the shadow
boundary, Korean and Chinese IME composition cycles, and yjs
convergence between two clients each rendered inside its own open
shadow root.
lexical-devtools descends through open shadow roots using the same
helpers this PR adds (getDOMShadowRoots / getActiveElementDeep /getEditorPropertyFromDOMNode), so it resolves the shadow-mounted
editor without dev-example-side glue. Closed-mode shadow roots remain
opaque to a page-level integration by spec — the concept page documents
the limitation and the browser-unit suite verifies the helpers behave
correctly when a host attaches a closed root.
A handful of newer platform APIs are not exercised by this single
example because they slot in at the page layer rather than the editor
itself: the
View Transitions API
animates between shadow-mounted instances on document.startViewTransition
without any editor-side glue, and the
CSS Custom Highlight API
(Highlight + ::highlight()) styles ranges that span the shadow
boundary as long as the page hands them un-retargeted boundary points —
the same shape getDOMSelectionRangeAndPoints already returns.
Overview (`examples/shadow-dom/README.md`)
Lexical Shadow DOM Example
A minimal Vite + React app that demonstrates Lexical
across a Shadow DOM boundary in a nested shape:
- An outer editor lives in the light DOM, with a
LexicalExtensionComposer
registering Rich Text, History, and Tab Indentation. - An inner editor is mounted inside an open
ShadowRoot, nested in the
outer editor's tree via aDecoratorNode(NestedEditorNode).
The nesting exercises Lexical's platform-only shadow support:
- Reading selection uses
Selection.getComposedRanges
andSelection.direction,
becauseSelection.anchorNode/getRangeAtare retargeted to the shadow
host when the selection is inside a shadow tree. - Reading focus uses
ShadowRoot.activeElement,
becausedocument.activeElementonly reports the outermost shadow host. - Writing selection uses the native
Selection.setBaseAndExtent, and
keyboard navigation / word + line deletion use the nativeSelection.modify,
both of which operate on shadow-tree nodes directly. - Selection-change attribution prefers the shadow-mounted candidate first
when aselectionchangefires under a nested layout — so the inner editor
wins attribution over its light-DOM parent.
How it works
ShadowRoot.tsx attaches an open shadow root to a host<div> with Element.attachShadow, then portals its children (including the
inner editor's contentEditable) into the shadow tree with createPortal.
React context flows across the portal, so the inner editor is built exactly as
it would be in the light DOM — only its DOM lives behind the shadow boundary.
The inner editor's CSS is injected as a <style> element inside the shadow
root, since shadow trees do not inherit the document's stylesheets.
The NestedEditorNode in App.tsx is a DecoratorNode whose
host sits in the outer editor's tree. Its decorate() returns a React subtree
that hosts the inner editor's LexicalExtensionComposer plus its shadow root —
so the inner editor lives at a real position inside the outer editor's
document.
The toolbar in Toolbar.tsx lives in the light DOM and
dispatches commands (FORMAT_TEXT_COMMAND, undo/redo) that act on the outer
editor's selection.
Running
pnpm i && pnpm run devTo run it against the Lexical sources in this repository instead of the
published packages, use pnpm run monorepo:dev.
Then open the printed URL. Try:
- Typing in either editor — outer (light DOM) or inner (shadow root).
- Selecting words in the outer editor with
Alt/Ctrl+Shift+ arrow keys
and pressing Bold / Italic / Underline in the toolbar. - Word and line deletion with
Alt/Ctrl+Backspace/Deletein either
editor.
Tests
Playwright tests in tests/ cover
rendering both editors, typing across the shadow boundary, formatting an outer
selection via the light-DOM toolbar, and word deletion in the inner editor.
They start the dev server automatically:
pnpm run testOverview (`examples/vanilla-js-iframe/README.md`)
Vanilla JS example in an iframe
A minimal Lexical editor built with buildEditorFromExtensions and no framework, using RichTextExtension, HistoryExtension, and DragonExtension (accessibility). The contentEditable lives inside an iframe, so the editor reads selection and focus from the iframe's window rather than the page's.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/vanilla-js-plugin/README.md`)
Basic Vanilla JS Extension example
An EmojiExtension that replaces smiles (:), :P, etc...) with actual emojis (using Node Transforms) and uses own graphics for emojis rendering by creating our own custom node that extends TextNode.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/vanilla-js/README.md`)
Basic Vanilla JS example
A rich text editor configured with one root extension. It composes RichTextExtension and HistoryExtension with buildEditorFromExtensions.
Run it locally: pnpm i && pnpm run dev
Overview (`examples/website-chat/README.md`)
Website Chat example
Here we have an example of a chat with rich text (@lexical/rich-text), emoji picker, and history (@lexical/history). Each sent message is rendered in a separate lightweight read-only lexical instance.
This example is used on the page https://lexical.dev/
Run it locally: pnpm i && pnpm run dev
Overview (`examples/website-notion/README.md`)
Website Notion example
Here we have an example Lexical setup in rich text configuration (@lexical/rich-text) with history (@lexical/history), draggable blocks with menu based on experimental DraggableBlockPLugin (@lexical/react/LexicalDraggableBlockPlugin), slash commands with floating menu (@lexical/react/LexicalTypeaheadMenuPlugin)
This example is used on the page https://lexical.dev/
Run it locally: pnpm i && pnpm run dev
Overview (`examples/website-rich-input/README.md`)
Website Rich Input example
Here we have an example Lexical setup in rich text configuration (@lexical/rich-text) with history (@lexical/history), hashtag (@lexical/hashtag) and custom character counter plugin
This example is used on the page https://lexical.dev/
Run it locally: pnpm i && pnpm run dev
Overview (`examples/website-toolbar/README.md`)
Website Toolbar example
This example demonstrates the implementation of a toolbar with a set of buttons that execute commands and set text formats for the selection. Buttons can be active depending on where the selection is set
This example is used on the page https://lexical.dev/
Run it locally: pnpm i && pnpm run dev
Overview (`packages/lexical-a11y/README.md`)
`@lexical/a11y`
Framework-agnostic accessibility helpers for Lexical: ARIA live regions, focus management, roving tab index, and focus trap. Usable from React, Svelte, Vue, Solid, or vanilla DOM. React-specific wrappers around the same primitives ship from @lexical/react.
Overview (`packages/lexical-clipboard/README.md`)
`@lexical/clipboard`
This package contains the functionality for the clipboard feature of Lexical.
Overview (`packages/lexical-code-core/README.md`)
`@lexical/code-core`
This package contains the base functionality for the code blocks and code highlighting for Lexical. This package
is considered an implementation detail and is being used transitionally while the deprecated highlighting
functionality is still available in @lexical/code.
Caret scrolling
When Lexical moves the caret itself, it scrolls the code block sideways to show the caret. That happens on the line
start and end keys in a code block (Home and End, or Cmd+Left and Cmd+Right on macOS), on Enter, on paste and on undo.
It also happens while you type in a highlighted code block, because the highlighter retokenizes the line and Lexical
puts the caret back. Plain arrow keys and clicks move the caret natively, and the browser scrolls for them. Extending a
selection with Shift does not scroll sideways.
When the block scrolls back toward the start of the line and the caret would also fit with it scrolled all the way back,
it goes all the way back. That shows the whole indentation, which Home and Enter leave the caret after. Otherwise it
scrolls just far enough, and keeps the caret out of the code element's scroll-padding. A gutter that stays in place
while the code scrolls should set that padding to its width, so the caret isn't left under it. A float: left gutter,
like the one the playground theme draws from data-gutter, is on the left in both directions, so usescroll-padding-left for it.
At the end of the longest line the block is scrolled as far as it goes, and a browser can draw the caret a fraction of a
pixel past that edge. A few pixels of padding-inline-end on the code element leave room for it. The playground theme
(PlaygroundEditorTheme.css) does both.
Overview (`packages/lexical-code-prism/README.md`)
`@lexical/code-prism`
This package contains the code highlighter for Lexical using the prism engine
Overview (`packages/lexical-code-shiki/README.md`)
`@lexical/code-shiki`
This package contains the code highlighter for Lexical using shikijs engine.
Overview (`packages/lexical-code/README.md`)
`@lexical/code`
This package contains the functionality for the code blocks for Lexical. It is a
thin re-export of @lexical/code-core and contains no syntax highlighting.
Code highlighting lives in its own packages: depend directly on@lexical/code-shiki or @lexical/code-prism for that. The deprecated prism
re-exports that this package used to forward from @lexical/code-prism have
been removed; import them from @lexical/code-prism instead.
Overview (`packages/lexical-compiler/README.md`)
`@lexical/compiler`
Lexical's build-time compiler: source-to-source passes over Lexical code,
delivered as a Vite/Rollup plugin and as plain transform functions for
builds with no plugin API of their own.
The tree-shaking pass inserts /* @__PURE__ */
annotations before module-scope calls to the side-effect-free factories
(defineExtension, createCommand, createState, safeCast,defineImportRule, …) so bundlers can drop the extension, command, and rule
definitions an application never uses; it replaces calls to the trivial ones
with the value they would have returned; and with strict it refuses to let
an unannotated call hide inside a definition, where it would pin the whole
thing into the bundle.
Lexical's published dist bundles already carry the annotations — this
package is for builds that compile Lexical from its TypeScript source:
- consumers that opt into the
sourceexport condition
(resolve.conditions: ['source', ...]), - a vendored copy or a git checkout of the monorepo,
- and your own application code, whose
defineExtension/createCommand
definitions get annotated the same way.
Why the annotations are needed
The factories are annotated with @__NO_SIDE_EFFECTS__ where they are
defined, but esbuild only honors that for calls in the same file as the
definition, and webpack/terser do not honor it at all. Bundlers do honor a/* @__PURE__ */ annotation at the call site, and that is what this
transform adds.
Argument-position calls matter too: a pure call is only removable when its
arguments are also side-effect-free, so an unannotated nested safeCast(...)
inside a defineExtension({...}) config pins the whole definition. The
transform annotates every module-scope call, nested ones included.
Calls inside function bodies, class fields, and static blocks are left alone —
they are not evaluated when the module is initialized, so an annotation there
has no effect on tree-shaking.
Factory calls are not the only module-scope statements a bundler has to keep.
esbuild and webpack keep, as a side effect, any property read
(navigator.userAgent, Date.now, SomeExtension.name), any call or new
they were not told is pure (new RegExp(...), Object.freeze(...),[...].join(',')), an array or object spread, an in test, a try
statement, a mutation (table.push(...), Klass.static = ...), and a class
with a computed member — and everything such a statement references, which is
how one LexicalEditor.version = ... after the class kept nearly all oflexical in a bundle that imported only createCommand. The fix is the same
mechanism: move the work into a function declared @__NO_SIDE_EFFECTS__ and
call it at module scope, so that this transform annotates the call and a
bundler can drop it when its result is unused. A call to a builtin or to a
third-party function that cannot be declared is annotated by hand at the call
site.
Guaranteeing a definition can be dropped
An annotated definition is only droppable when everything it is built from is
too, so a single unannotated call in its arguments quietly pins it into every
bundle that imports the module. With strict: true that is a build error
naming the call rather than something you find later in a bundle analyzer:
@lexical/compiler: 1 call(s) evaluated inside a definition in
src/MdastFootnoteExtension.ts are not known to be side-effect free, so the
definition cannot be tree-shaken:
1145:25 gfmFootnoteFromMarkdown(...) inside defineExtension(...)
Make the call lazy, or declare the function side-effect free
(@__NO_SIDE_EFFECTS__ plus the transform's `functions`/`namespaces` options)
so that its calls are annotated too.Calls that only run later (a nodes: () => [...] callback, a register
method body) are not counted, and neither are the built-ins every bundler
already knows are pure (new Map(...), new Set(...), Object, Symbol,
…). Modules under node_modules are annotated as usual but never
checked: a definition a dependency shipped is not yours to fix. Lexical's own
build runs with strict on.
Usage
Each pass is its own entry point, so a build that wants one of them does not
load the rest: @lexical/compiler/PureAnnotations is the tree-shaking pass,
and @lexical/compiler re-exports every pass for convenience.
Vite
// vite.config.js
import {pureAnnotations} from '@lexical/compiler/PureAnnotations';
import {defineConfig} from 'vite';
export default defineConfig({
plugins: [pureAnnotations()],
resolve: {conditions: ['source']},
});The plugin defaults to enforce: 'post' so it runs after Vite has compiled
TypeScript and JSX away.
Rollup
// rollup.config.js
import {pureAnnotations} from '@lexical/compiler/PureAnnotations';
export default {
plugins: [
// ... any TypeScript/JSX plugin first
pureAnnotations(),
],
};Any other bundler
transformPureAnnotations is the transform itself, with no bundler
integration, so it can be wrapped in a webpack loader or run as a codemod:
import {transformPureAnnotations} from '@lexical/compiler/PureAnnotations';
const result = transformPureAnnotations(code, {filename});
// `null` when the module needs no annotations
const annotated = result === null ? code : result.code;Which calls get annotated
A call is annotated only when the transform can establish that the function
being called really is side-effect free. It resolves the callee to its binding
in the module and annotates the call when that binding is:
- imported from a Lexical package — a specifier matching
lexicalor@lexical/*(configurable withsources). Every factory inPURE_FACTORY_FUNCTIONSis declared@__NO_SIDE_EFFECTS__in those
packages, so the import is evidence enough. Aliased imports
(import {defineExtension as define}) are resolved too, and so is a
default import from a Lexical module named for its export
(import warnOnlyOnce from '@lexical/internal/warnOnlyOnce'); - declared in the same module with
@__NO_SIDE_EFFECTS__, or imported
from a relative module that declares it that way — the imported file is
read and parsed to check (turn this off withrelativeImports: false).
Such a declaration is evidence on its own, so a factory of your own does
not have to be named infunctions: mark it and its module-scope calls are
annotated; or - a method of a pure namespace — an object whose methods build values and
touch nothing else, like@lexical/html'ssel, so thatsel.tag('span').attr('data-x', true)is annotated the way a factory call
is. The names arePURE_NAMESPACES(configurable withnamespaces) when
imported from a Lexical package, and any object marked@lexical-pure-namespacewhere it is declared, including through a
relative import or a local alias. Only the outermost call of a chain is
annotated — rollup, terser and esbuild all drop the whole chain from that
one.
Anything else is left alone. Your own safeCast from ./utils, or acreateCommand from some other library, will not be annotated just because it
shares a name with a Lexical factory — annotating a call that does have side
effects would let a bundler drop it. Mark your own factories@__NO_SIDE_EFFECTS__ (and add them to functions) to opt them in.
Eliding the trivial factories
Some of the factories are pure type-level helpers whose implementation is a
trivial expression over their own arguments:
| Factory | Returns | Form |
|---|---|---|
safeCast(value) |
value |
identity |
defineExtension(extension) |
extension |
identity |
defineImportRule(rule) |
rule |
identity |
configExtension(...args) |
args |
args |
declarePeerDependency(...args) |
args |
args |
With inline: true the call is replaced by that expression instead of being
annotated:
export const MarkExtension = defineExtension({name: '@lexical/mark', ...});
// becomes
export const MarkExtension = {name: '@lexical/mark', ...};which is better than an annotation in every way that matters: a literal is
inert for every bundler with nothing to preserve through minification, there
is no nested call left to pin the definition, and the factory itself can be
dropped from the bundle once nothing calls it (the import it came from is
removed along with the last call to it).
A call that does not fit its form is annotated as usual: a spread argument
where a single parameter is expected, or a call whose result is discarded.
The replacement is parenthesized wherever bare syntax would not bind the way
the call did — safeCast(1 + 2) * 3 is not 1 + 2 * 3, an object literal at
the start of an expression statement would read as a block, and an args
array is always wrapped, since a leading [ is a member access on whatever
the previous line ended with. Minifiers drop the parentheses again.
createCommand is deliberately not inlined: {type: x} is more bytes than a
call to a minified one-character name.
Each inlined factory is marked @lexical-inline <form> where it is defined.
For an import from a Lexical package the marker is documentation — the table
is trusted, because this package ships alongside that Lexical. Everywhere
else (a factory of your own, declared in the module or imported relatively)
the marker is what makes inlining possible at all: @__NO_SIDE_EFFECTS__
says a call is safe to drop, but only the marker says what the call can be
replaced with, so a same-named look-alike is annotated instead. Add your
factory's name to functions, mark it with the form its body takes, and its
calls are inlined like Lexical's own.
A marker that names a form the transform does not implement (a typo, or a
form from an older version) throws, naming the file and the forms it accepts.
Failing is the point: a marker that silently did nothing would leave the
factory un-inlined with no sign of it.
This is off by default because it reproduces those function bodies, so it
assumes the Lexical you are building matches this package's version — they
are released together with the same version number. Lexical's own build turns
it on for the bundles it publishes. The package's tests check that the
markers in the tree and the table agree, and that the real functions still
return what the table claims.
Options
| Option | Default | Description |
|---|---|---|
inline |
false |
Replace calls to the trivial factories with the literal they would have returned (see above). |
functions |
PURE_FACTORY_FUNCTIONS |
Names of the factories whose module-scope calls are annotated. Pass your own list (or [...PURE_FACTORY_FUNCTIONS, 'myFactory']) to cover factories of your own. |
namespaces |
PURE_NAMESPACES |
Names of objects whose method calls are annotated (see above). |
sources |
[/^lexical$/, /^@lexical\//] |
RegExp (or array) of module specifiers whose exports are trusted to be the factories without reading them. |
strict |
false |
Throw when a call evaluated inside one of the definitions is not known to be side-effect free (see above). |
relativeImports |
true |
Whether to read relatively imported modules to look for a @__NO_SIDE_EFFECTS__ declaration. |
include |
every .js/.jsx/.ts/.tsx/.mjs/.cjs/.mts/.cts module |
RegExp (or array) matched against the module id with any query string removed. |
exclude |
none | RegExp (or array) of module ids to skip. |
enforce |
'post' |
Vite plugin ordering. |
parserPlugins |
none | Extra @babel/parser plugins, for syntax the defaults do not cover. |
sourceMap |
true |
Set to false to skip source map generation. |
Modules that fail to parse are passed through unchanged with a warning rather
than failing the build.
The transform is idempotent: a call that already has a /* @__PURE__ */ (or
terser's /* #__PURE__ */) annotation immediately before it is left alone.
Subpath imports without tree-shaking
subpathImports redirects named imports and re-exports from@lexical/extension to their public subpaths. For example,import {namedSignals, defineExtension} from '@lexical/extension' becomes
imports from @lexical/extension/namedSignals and lexical. It reads the
installed version's barrel, including aliases, rather than using a fixed
list of symbols.
import {subpathImports} from '@lexical/compiler/SubpathImports';
export default {
plugins: [subpathImports()],
};Use this Rollup/Vite plugin before dependency resolution. It accepts
TypeScript, TSX and JavaScript, including CommonJS dependencies, standard
decorators and legacy TypeScript parameter decorators, so it can run before
or after TypeScript transpilation. Unlike pureAnnotations, it also helps
builds that do not tree-shake: unused extension modules never become
dependencies in the first place. It does not make an individual subpath
smaller than that subpath's own dependency graph.
Options:
packages: package names, or paths to theirpackage.jsonfiles. Defaults
to['@lexical/extension']. Each package must publish its source files via
thesourceexport condition and declaresideEffects: false. Only packages
with multiple public entries and transparent re-export barrels are narrowed;
executable root entries are left intact.root: directory from which package names are resolved. Defaults toprocess.cwd(). Use absolute package.json paths for an unbuilt checkout.parserPlugins: extra@babel/parserplugins, appended to the defaults
for each filename. Accepts plugin names and[name, options]tuples, just
likepureAnnotations. Applies to consumers and package source files read
to derive the export mapping. For example,parserPlugins: ['doExpressions']
enables parsing that syntax; your downstream compiler must still transform it.flowandflowCommentsare omitted for TypeScript filenames, so Flow
consumers can import TypeScript packages without enabling both languages
for the same file. This also applies topureAnnotations.strict: defaults tofalse. Set totrueto reject unmappable default
imports, namespace imports, side-effect imports, star re-exports, dynamic
imports, TypeScript import assignments andrequirecalls
targeting a barrel. These forms load the whole namespace and cannot be
narrowed safely. With the default setting they keep their original
behavior. An explicitly re-exported default is narrowed like a named export.
Checks include access inside exported declarations. Type-only imports are
preserved and erased by TypeScript. String literals and template literals
without substitutions are checked; computed module names cannot be narrowed.
In Rollup watch builds and Vite development servers, changes to the package export
map or barrel bindings refresh the mapping and invalidate cached consumer
transforms. Vite also includes those consumers in the hot update even when
rewriting removed the barrel from their dependency graph.
Relative imports between public entry points are also converted to package
subpaths. Lexical's package build keeps those subpaths external, so a shared
module such as signals has one instance across entries. The plugin does
not change a bundler's external configuration: a downstream application can
bundle the subpaths together normally.
Lexical runs this pass in strict mode for all its published package builds,
including www. Consumers can continue to import the complete barrel API
without using any compiler plugin. Barrel and subpath exports reference the
same functions, classes, extensions and signals runtime.
In this repository, source code imports from the @lexical/extension barrel
across package boundaries and uses relative imports within that package,
including the barrel's own re-exports. ESLint enforces this convention;
the compiler handles the published subpath layout. Direct subpath imports
remain supported for downstream consumers, including applications that need
small bundles without tree-shaking or the compiler plugin.
SchemaJsonCodegen
The package's second entry point, @lexical/compiler/SchemaJsonCodegen, is
not part of the plugin pipeline: it is the library a code generator uses to
turn a node's declarative serialization schema (the json property of$config) into straight-line JavaScript. Lexical's ownscripts/generate-node-json.mjs uses it to generate the specializedexportJSON/updateFromJSON implementations the built-in nodes ship with.
compileParse(meta, defaultValue, tableBaseName)compiles a schema's
introspectablemetainto a JavaScript expression overvthat parses
exactly as the schema does, plus any lookup tables the expression refers
to. Only the kinds whose meta fully determines the parse are compiled
(strings, numbers, booleans, enums, andaliasedValuetables); the rest
throwNotCompilablerather than emit a guess.- Compiling is not trusting:
verifyCompiledParseruns the compiled
expression against the real schema oververificationCorpus(meta)— every
value the schema names plus a fixed set of hostile inputs,Object.prototypemember names included — and throws naming the first
value they disagree on.verifyTableCoversDomainproves an emitted lookup
table total over a schema's domain, so its miss-fallback is dead code. - The compact form's comparisons are compiled the same way.
compileDiffersFromDefault(schema, name)states the test that a value is
not the schema's default — a literal for a primitive default, and the
length testarrayValue's equality reduces to for an empty-array default —
andverifyDiffersFromDefaultruns it against the schema's ownisEqual
over the corpus. NUM_HELPER_SOURCEandJSON_NUMBER_SOURCEare the number-parsing helper
as source text, so an emitted module and the verification share one
definition rather than two copies that could drift;NUM_RANGE_HELPER_SOURCE
is the constrained-domain variant.
The corpus is fixed rather than sampled, so a generator built on this
produces byte-reproducible output — which is what lets generated files be
checked in and drift-tested against regeneration.
Overview (`packages/lexical-devtools-core/README.md`)
`@lexical/devtools-core`
This package contains tools necessary to debug and develop Lexical.
Overview (`packages/lexical-devtools/README.md`)
Lexical DevTools browser extension
This is the source code for the Lexical DevTools browser extension.
Local development
Lexical DevTools extension uses WXT framework to simplify development. Please refer to WXT Development Guide for comprehensive documentation.
TLDR:
$ pnpm run dev
# In browser: Alt+R to force reload extensionUseful Hints:
- Extension activity log: chrome://extensions/?activity=eddfjidloofnnmloonifcjkpmfmlblab
- Status of ServiceWorkers: chrome://serviceworker-internals/?devtools
- WXT Framework debugging:
DEBUG_WXT=1 pnpm run dev - If you detach the Dev Tools in a separate window, and press
Cmd+Option+Iwhile Dev Tools window is focused, you will invoke the Dev Tools for the Dev Tools window.
Safari:
To develop and run Safari version of the extension you (obviously) need a Mac and Xcode installed. Safari on the contrary to other browsers doesn't accept web extensions as a zip archive but rather requires you to wrap it in native code (Swift) wrapper. Fortunately this process is mostly automated here.
# Install Xcode
# Environment setup
sudo xcode-select -s /Applications/Xcode.app
xcodebuild --install
sudo xcodebuild -license
xcodebuild -runFirstLaunch
# Normal operation
pnpm run dev:safari
# Build & upload to Apple Connect
BUILD_VERSION=0 pnpm run safari:archive
PASSWORD="XXX" pnpm run safari:uploadPublishing
Production (latest) npm releases submit Chrome and Firefox extensions for review from the released commit, with build version 0. Update store screenshots/videos before releases that change the UI.
For extension-only releases or retries, run Publish DevTools extension to stores on the release tag. Increase "Build version" for repeated uploads of the same Lexical version (<version>.<build_version>), and wait for pending Chrome reviews to finish. Store submissions can fail independently; retry this workflow without republishing npm packages.
Safari releases use the manual archive/upload commands.
Store credentials
Enable the Chrome Web Store API in Google Cloud and link a publishing service account to the listing's publisher. Configure these repository settings:
| Setting | Type | Value |
|---|---|---|
EXTENSION_CHROME_EXTENSION_ID |
Secret | Listing's extension ID |
EXTENSION_CHROME_PUBLISHER_ID |
Variable | Publisher > Settings in the Developer Dashboard |
EXTENSION_CHROME_SERVICE_ACCOUNT_CLIENT_EMAIL |
Variable | Service account's client_email |
EXTENSION_CHROME_SERVICE_ACCOUNT_PRIVATE_KEY |
Secret | Decoded private_key PEM with actual newlines, not the full JSON key |
Firefox requires repository secrets EXTENSION_FIREFOX_EXTENSION_ID, EXTENSION_FIREFOX_JWT_ISSUER, and EXTENSION_FIREFOX_JWT_SECRET.
Confirm Chrome visibility and rollout in the Developer Dashboard. Visibility changes require one manual publish before API publishing. wxt submit --dry-run contacts Google.
Marketplace access
Contact Vladlen Fedosov or Christopher Howard at EPAM Open Source Office for marketplace access. Chrome access requires a publisher administrator. For Firefox, enable two-factor authentication, set a display name, and provide your Mozilla account email to an existing maintainer.
Design
This extension follows typical Browser DevTools architecture that includes sereral independent contexts that communicate via events or extension APIs.
Overview (`packages/lexical-dragon/README.md`)
`@lexical/dragon`
This package adds compatibility with Dragon NaturallySpeaking, a speech
recognition tool whose web extension drives editable fields through window
messages. The handler applies those edits through Lexical APIs and stops the
messages from reaching the extension's own handler, which would otherwise
edit the DOM directly behind Lexical's back.
Editors register automatically through DragonExtension (a dependency of@lexical/rich-text and @lexical/plain-text extensions) or by callingregisterDragonSupport(editor).
Registering early
Browsers invoke a window's message listeners in registration order, and the
Dragon extension's content script registers its listener at document_end
of the initial page load. Editors that mount later (after a navigation in a
single page app, in a dialog, in any lazily rendered view) register their
handler after the extension's, too late to block it, and edits end up
applied twice.
To win that race, call installDragonSupport() synchronously from every
entrypoint that may render an editor lazily, so the shared listener is
registered before the extension's:
import {installDragonSupport} from '@lexical/dragon';
installDragonSupport();It returns a teardown function; the listener is removed once every teardown
(including the ones from registerDragonSupport) has been called.
For editors mounted inside iframes, pass the iframe's window so the
listener is installed against the right one. State is kept per window,
so multiple frames can coexist:
installDragonSupport(iframe.contentWindow);Overview (`packages/lexical-eslint-plugin/README.md`)
`@lexical/eslint-plugin`
This ESLint plugin enforces the Lexical $function convention.
ESLint Compatibility: This plugin supports ESLint 9 and later with the flat config format (eslint.config.js). Support for ESLint 7-8 and the legacy .eslintrc format was removed in v0.51.0.
Installation
Assuming you already have ESLint installed, run:
npm install @lexical/eslint-plugin --save-devConfiguration
Add the recommended config to your eslint.config.js:
import lexical from '@lexical/eslint-plugin';
export default [
// ... other configs
lexical.configs.recommended
];lexical.configs['flat/recommended'] and lexical.configs['flat/all'] are
aliases of recommended and all from when those names were the legacy
configs.
Custom Configuration
import lexical from '@lexical/eslint-plugin';
export default [
{
plugins: {
'@lexical': lexical
},
rules: {
'@lexical/rules-of-lexical': 'error'
}
}
];Advanced configuration
Most of the heuristics in @lexical/rules-of-lexical can be extended with
additional terms or patterns.
The code example below is shown using the default implementations for each
option. When you configure these they are combined with the default
implementations using "OR", the default implementations can not be overridden.
These terms and patterns are only shown for reference and pasting this example
into your project is not useful.
If the string begins with a "^" or "(" then it is treated as a RegExp,
otherwise it will be an exact match. A string may also be used instead
of an array of strings.
import lexical from '@lexical/eslint-plugin';
export default [
{
plugins: {
'@lexical': lexical
},
rules: {
'@lexical/rules-of-lexical': [
'error',
{
isDollarFunction: ['^\\$[a-z_]'],
isIgnoredFunction: [],
isLexicalProvider: [
'parseEditorState',
'read',
'registerCommand',
'registerNodeTransform',
'update'
],
isSafeDollarFunction: ['^\\$is']
}
]
}
}
];`isDollarFunction`
Base case: /^\$[a-z_]/
This defines the $function convention, which by default is any function that
starts with a dollar sign followed by a lowercase latin letter. You may have a
secondary convention in your codebase, such as non-latin letters, or an
internal prefix that you want to consider (e.g. "^INTERNAL_\\$").
`isIgnoredFunction`
Base case: None
Functions that match these patterns are ignored from analysis, they may call
Lexical $functions but are not considered to be a dollar function themselves.
`isLexicalProvider`
Base case: /^(parseEditorState|read|registerCommand|registerNodeTransform|update)$/
These are functions that allow their function argument to use Lexical
$functions.
`isSafeDollarFunction`
Base case: /^\$is/
These $functions are considered safe to call from anywhere, generally
these functions are runtime type checks that do not depend on any other
state.
Testing
To verify that the plugin works with different ESLint versions, run the integration tests:
pnpm run test-eslint-integrationThis will test:
- ✓ ESLint 9 with a flat
eslint.config.jsconfiguration - ✓ ESLint 10 with the same configuration
The tests use pnpm dlx to run different ESLint versions without modifying package.json or pnpm-lock.yaml.
Valid and Invalid Examples
Valid Examples
$functions may be called by other $functions
function $namedCorrectly() {
return $getRoot();
}$functions may be called in functions defined when calling the following
methods (the heuristic only considers the method name):
editor.updateeditorState.readeditor.registerCommandeditor.registerNodeTransform
function validUsesEditorOrState(editor) {
editor.update(() => $getRoot());
editor.getLatestState().read(() => $getRoot());
}$functions may be called from class methods
class CustomNode extends ElementNode {
appendText(string) {
this.appendChild($createTextNode(string));
}
}Invalid Examples
Rename autofix
function invalidFunction() {
return $getRoot();
}
function $callsInvalidFunction() {
return invalidFunction();
}Autofix: The function is renamed with a $ prefix. Any references to this
name in this module are also always renamed.
function $invalidFunction() {
return $getRoot();
}
function $callsInvalidFunction() {
return $invalidFunction();
}Rename & deprecate autofix
export function exportedInvalidFunction() {
return $getRoot();
}Autofix: The exported function is renamed with a $ prefix. The previous name
is also exported and marked deprecated, because automatic renaming of
references to that name is limited to the module's scope.
export function $exportedInvalidFunction() {
return $getRoot();
}
/** @deprecated renamed to {@link $exportedInvalidFunction} by @lexical/eslint-plugin rules-of-lexical */
export const exportedInvalidFunction = $exportedInvalidFunction;Rename scope conflict
import {$getRoot} from 'lexical';
function InvalidComponent() {
const [editor] = useLexicalComposerContext();
const getRoot = useCallback(() => $getRoot(), []);
return ( editor.update(() => getRoot())} />);
}Autofix: The function is renamed with a $ prefix and _ suffix since the suggested name was already in scope.
import {$getRoot} from 'lexical';
function InvalidComponent() {
const [editor] = useLexicalComposerContext();
const $getRoot_ = useCallback(() => $getRoot(), []);
return ( editor.update(() => $getRoot_())} />);
}Overview (`packages/lexical-extension/README.md`)
`@lexical/extension`
This package provides the extension functionality for Lexical.
Overview (`packages/lexical-fast-check/README.md`)
`@lexical/fast-check`
This package derives fast-check arbitraries from the
serialization schemas Lexical nodes declare on $config, so property-based tests
generate exactly the serialized JSON a node's parser accepts.
Install it alongside fast-check, which is a peer dependency:
npm install --save-dev @lexical/fast-check fast-checkUsage
nodeArbitrary(NodeClass) generates the node-specific properties of a
serialized node, composing the schemas the class inherits (an element-based
node also gets direction/format/indent/… from ElementNode). Every
property is generated independently as present or absent, because that is the
domain parsers actually face: an older document predates a property, and a
compact export omits one whose value equals its default.
The values are typed as the parse shape of the class's own serialized type —LexicalParseJSON<SerializedMyNode> for a MyNode that declaresexportJSON(): SerializedMyNode — which is what its updateFromJSON accepts,
so they can be handed to it without a cast.
import {nodeArbitrary} from '@lexical/fast-check';
import * as fc from 'fast-check';
fc.assert(
fc.property(nodeArbitrary(MyNode), props => {
const json = {...props, type: 'my-node'};
// importing and re-exporting is a fixed point
expect(exportOf(importOf(json))).toEqual(exportOf(importOf(exportOf(importOf(json)))));
}),
);nodeArbitrary reads the class's $config, so no editor or node instance is
required — but resolving it injects the class's synthesized statics and compiles
its accessor tables, so a class whose schema names an accessor it does not have
throws here. To inspect what a node declares without generating anything, callgetComposedSchemaFields(NodeClass) from lexical directly.
Example: proving a clone carries your node's properties
A custom node has to copy its own properties in afterCloneFrom, which Lexical
calls whenever getWritable() clones the node. Declaring a new property and
forgetting to copy it there is a silent bug: the field still exists on the clone
because the constructor set it, so nothing throws — the node just reverts that
one property to its default the next time anything writes to it.
This is a good fit for a generated test, because a hand-written fixture tends to
use default values, and a dropped property compares equal to its default. The
test looks like it passed precisely when the bug is invisible. nodeArbitrary
draws from the schema's own domain instead, so the values are in-domain and
mostly not the default:
/* Detailed source-code truncated for AI context efficiency. */When it fails, fast-check shrinks to the smallest value that exposes it, which
names the property for you. Deleting this.__style = prevNode.__style fromTextNode.afterCloneFrom reports:
Counterexample: [{"style":" "}]
- "style": " ",
+ "style": "",A single space — the smallest style that is not the default '', and the only
kind of value that can catch this.
Overview (`packages/lexical-file/README.md`)
`@lexical/file`
This package contains the functionality for the file import/export feature of Lexical.
Overview (`packages/lexical-hashtag/README.md`)
`@lexical/hashtag`
This package contains the functionality for Lexical hashtags.
Overview (`packages/lexical-headless/README.md`)
`@lexical/headless`
This package allows you to interact with Lexical in a headless environment (one that does not rely on DOM, e.g. for Node.js environment), and use its
main features like editor.update(), editor.registerNodeTransform(), editor.registerUpdateListener()
to create, update or traverse state.
Install @lexical/headless:
npm install --save @lexical/headlessimport { createHeadlessEditor } from '@lexical/headless';
const editor = createHeadlessEditor({
nodes: [],
onError: () => {},
});
editor.update(() => {
$getRoot().append(
$createParagraphNode().append(
$createTextNode('Hello world')
)
)
});An editor built from extensions does not need createHeadlessEditor: an
editor that is never given a root element already skips all DOM work. AddHeadlessExtension to make that explicit. It marks the editor (and any nested
editors) as headless and makes setRootElement throw if it is given an
element, while extensions that register root or mutation listeners keep
working.
import { buildEditorFromExtensions } from '@lexical/extension';
import { HeadlessExtension } from '@lexical/headless';
import { RichTextExtension } from '@lexical/rich-text';
import { defineExtension } from 'lexical';
const editor = buildEditorFromExtensions(
defineExtension({
name: '[root]',
dependencies: [HeadlessExtension, RichTextExtension],
}),
);Lexical packages are published as ES modules. A CommonJS project can load them
with require() on Node.js 20.19 or later, but any dependency it shares with
Lexical (such as yjs) has to be loaded as an ES module as well, or the
project ends up with two copies of it; see
Which module formats are published?.
Any plugins that do not rely on DOM could also be used. Here's an example of how
you can convert lexical editor state to markdown on server:
import { createHeadlessEditor } from '@lexical/headless';
import { $convertToMarkdownString, TRANSFORMERS } from '@lexical/markdown';
app.get('article/:id/markdown', async (req, res) => {
const editor = createHeadlessEditor({
nodes: [],
onError: () => {},
});
const articleEditorStateJSON = await loadArticleBody(req.query.id);
editor.setEditorState(editor.parseEditorState(articleEditorStateJSON));
editor.update(() => {
const markdown = $convertToMarkdownString(TRANSFORMERS);
res.send(markdown);
});
});Overview (`packages/lexical-history/README.md`)
`@lexical/history`
This package contains history helpers for Lexical.
Methods
`registerHistory`
Registers necessary listeners to manage undo/redo history stack and related editor commands. It returns unregister callback that cleans up all listeners and should be called on editor unmount.
function registerHistory(
editor: LexicalEditor,
externalHistoryState: HistoryState,
delay: number,
): () => voidCommands
History package handles UNDO_COMMAND, REDO_COMMAND and CLEAR_HISTORY_COMMAND commands. These commands could be used to work with history state:
import {UNDO_COMMAND, REDO_COMMAND} from 'lexical';
<Toolbar>
editor.dispatchCommand(UNDO_COMMAND, undefined)}>Undo
editor.dispatchCommand(REDO_COMMAND, undefined)}>Redo
</Toolbar>;Undo/redo availability
CAN_UNDO_COMMAND and CAN_REDO_COMMAND are deprecated. A command only reports a change, so a listener registered after the editor is initialized never sees the current value, and there is no way to read it.
Use the canUndo and canRedo signals from HistoryExtension instead. They are derived from the history stacks and always hold the current value:
In React, use the useExtensionSignalValue hook:
import {HistoryExtension} from '@lexical/history';
import {useExtensionSignalValue} from '@lexical/react/useExtensionSignalValue';
function UndoButton() {
const canUndo = useExtensionSignalValue(HistoryExtension, 'canUndo');
return Undo;
}Outside React, read the signal directly:
import {getExtensionDependencyFromEditor} from '@lexical/extension';
import {HistoryExtension} from '@lexical/history';
const {output} = getExtensionDependencyFromEditor(editor, HistoryExtension);
output.canUndo.peek();Overview (`packages/lexical-html/README.md`)
`@lexical/html`
HTML
This package exports utility functions for converting Lexical -> HTML and HTML -> Lexical. These same functions are also used in the lexical-clipboard package for copy and paste.
Full documentation can be found here.
Exporting
// In a headless mode, you need to initialize a headless browser implementation such as JSDom.
const dom = new JSDOM();
// @ts-expect-error
global.window = dom.window;
global.document = dom.window.document;
// You may also need to polyfill DocumentFragment or navigator in certain cases.
// When converting to HTML you can pass in a selection object to narrow it
// down to a certain part of the editor's contents.
const htmlString = $generateHtmlFromNodes(editor, selection | null);Importing
First we need to parse the HTML string into a DOM instance.
// In the browser you can use the native DOMParser API to parse the HTML string.
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString, textHtmlMimeType);
// In a headless environment you can use a package such as JSDom to parse the HTML string.
const dom = new JSDOM(htmlString);And once you have the DOM instance.
const nodes = $generateNodesFromDOM(editor, dom);
// Once you have the lexical nodes you can initialize an editor instance with the parsed nodes.
const editor = createEditor({ ...config, nodes });
// Or insert them at a selection.
$insertNodes(nodes);Overview (`packages/lexical-internal/README.md`)
`@lexical/internal`
[!WARNING]
This package contains internal utilities shared across the Lexical
packages:invariant/devInvariant,createError, dev/prod error- and warning-message
formatting,warnOnlyOnce, and theLEXICAL_VERSIONconstant. These are
bound to the build's transforms and are inlined into every other package
rather than shipped as a runtime dependency, so it is published only so the
modules resolve through normal package resolution — including thesource
export condition used when developing against a linked checkout.It is not a public API. Import from
lexicaland the@lexical/*
feature packages instead. Nothing here follows semver and any export may
change or disappear without notice.
createError('Message with %s', value) constructs an Error without throwing
it, for example to pass to an editor's onWarn handler. It shares invariant's
literal-message and %s argument convention, including production error-code
extraction, and can be used inside expressions.
The transform recognizes the default import from@lexical/internal/createError (or its .js alias), including renamed imports.
Unrelated or shadowed functions with the same name are left alone. Generated
calls use separate createDevError and createProdError runtime helpers so
bundled output can pass through the transform again.
Warnings routed through createError, including the update-cascade warning,
use the minified error message in production builds with extracted error codes.
Arguments such as the editor namespace are encoded as v parameters in the
decoder URL instead of appearing directly in the message text. Telemetry that
matches the full development warning text must account for that production form.
Overview (`packages/lexical-link/README.md`)
`@lexical/link`
This package contains the functionality for Lexical links.
Overview (`packages/lexical-list/README.md`)
`@lexical/list`
This package exposes the primitives for implementing lists in Lexical. If you're trying to implement conventional lists with React, take a look at the ListPlugin exposed
by @lexical/react, which wraps these primitives into a neat component that you can drop into any LexicalComposer.
The API of @lexical/list primarily consists of Lexical Nodes that encapsulate list behaviors and a set of functions that can be called to trigger typical list manipulation functionality:
Functions
$insertList
As the name suggests, this inserts a list of the provided type according to an algorithm that tries to determine the best way to do that based on
the current Selection. For instance, if some text is selected, $insertList may try to move it into the first item in the list. See the API documentation for more detail.
$removeList
Attempts to remove lists inside the current selection based on a set of opinionated heuristics that implement conventional editor behaviors. For instance, it converts empty ListItemNodes into empty ParagraphNodes.
Nodes
ListNode
ListItemNode
Commands
For convenience, we provide a set of commands that can be used to connect a plugin to trigger typical list manipulation functionality:
INSERT_UNORDERED_LIST_COMMAND
INSERT_ORDERED_LIST_COMMAND
INSERT_CHECK_LIST_COMMAND
REMOVE_LIST_COMMAND
It's important to note that these commands don't have any functionality on their own. They are just for convenience and require you to register a handler for them in order to actually change the editor state when they are dispatched, as below:
// MyListPlugin.ts
editor.registerCommand(INSERT_UNORDERED_LIST_COMMAND, () => {
$insertList(editor, 'bullet');
return true;
}, COMMAND_PRIORITY_LOW);
// MyInsertListToolbarButton.ts
function onButtonClick(e: MouseEvent) {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
}Theming
Lists can be styled using the following properties in the EditorTheme passed to the editor in the initial config (the values are classes that will be applied in the denoted contexts):
{
list?: {
// Applies to all lists of type "bullet"
ul?: EditorThemeClassName;
// Used to apply specific styling to nested levels of bullet lists
// e.g., [ 'bullet-list-level-one', 'bullet-list-level-two' ]
ulDepth?: Array<EditorThemeClassName>;
// Applies to all lists of type "number"
ol?: EditorThemeClassName;
// Used to apply specific styling to nested levels of number lists
// e.g., [ 'number-list-level-one', 'number-list-level-two' ]
olDepth?: Array<EditorThemeClassName>;
// Applies to all list items
listitem?: EditorThemeClassName;
// Applies to all list items with checked property set to "true"
listitemChecked?: EditorThemeClassName;
// Applies to all list items with checked property set to "false"
listitemUnchecked?: EditorThemeClassName;
// Applies only to list and list items that are not at the top level.
nested?: {
list?: EditorThemeClassName;
listitem?: EditorThemeClassName;
};
};
}Overview (`packages/lexical-mark/README.md`)
`@lexical/mark`
This package contains helpers and nodes for wrapping content in marks for Lexical.
Overview (`packages/lexical-markdown/README.md`)
`@lexical/markdown`
This package contains markdown helpers for Lexical: import, export and shortcuts.
Import and export
import {
$convertFromMarkdownString,
$convertToMarkdownString,
TRANSFORMERS,
} from '@lexical/markdown';
editor.update(() => {
const markdown = $convertToMarkdownString(TRANSFORMERS);
...
});
editor.update(() => {
$convertFromMarkdownString(markdown, TRANSFORMERS);
});It can also be used for initializing editor's state from markdown string. Here's an example with react <RichTextPlugin>
<LexicalComposer initialConfig={{
editorState: () => $convertFromMarkdownString(markdown, TRANSFORMERS)
}}>
<RichTextPlugin />
</LexicalComposer>Shortcuts
Can use <MarkdownShortcutPlugin> if using React
import { TRANSFORMERS } from '@lexical/markdown';
import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
<LexicalComposer>
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
</LexicalComposer>Or registerMarkdownShortcuts to register it manually:
import {
registerMarkdownShortcuts,
TRANSFORMERS,
} from '@lexical/markdown';
const editor = createEditor(...);
registerMarkdownShortcuts(editor, TRANSFORMERS);Transformers
Markdown functionality relies on transformers configuration. It's an array of objects that define how certain text or nodes
are processed during import, export or while typing. @lexical/markdown package provides set of built-in transformers:
// Element transformers
UNORDERED_LIST
CODE
HEADING
ORDERED_LIST
QUOTE
// Text format transformers
BOLD_ITALIC_STAR
BOLD_ITALIC_UNDERSCORE
BOLD_STAR
BOLD_UNDERSCORE
INLINE_CODE
ITALIC_STAR
ITALIC_UNDERSCORE
STRIKETHROUGH
// Text match transformers
LINKAnd bundles of commonly used transformers:
TRANSFORMERS- all built-in transformersELEMENT_TRANSFORMERS- all built-in element transformersMULTILINE_ELEMENT_TRANSFORMERS- all built-in multiline element transformersTEXT_FORMAT_TRANSFORMERS- all built-in text format transformersTEXT_MATCH_TRANSFORMERS- all built-in text match transformers
Transformers are explicitly passed to markdown API allowing application-specific subset of markdown or custom transformers.
There're three types of transformers:
- Element transformer handles top level elements (lists, headings, quotes, tables or code blocks)
- Text format transformer applies text range formats defined in
TextFormatType(bold, italic, underline, strikethrough, code, subscript and superscript) - Text match transformer relies on matching leaf text node content
See MarkdownTransformers.js for transformer implementation examples
Overview (`packages/lexical-mdast/README.md`)
`@lexical/mdast`
⚠️ Experimental: everything in this package is marked
@experimentaland may change between any two Lexical releases —
including breaking renames, signature changes, or behavior changes —
until the API stabilizes.@lexical/markdownremains the supported
default for production apps that don't want to track an experimental
API.
An alternative to @lexical/markdown that is built on the
micromark /
mdast ecosystem.
Where @lexical/markdown ships its own regular-expression based parser, this
package delegates Markdown parsing and serialization to micromark andmdast-util-*. That means CommonMark + GFM compliance comes from the same
parser used by remark, and Markdown shortcuts are recognized by feeding
keystrokes back through that same parser — there is no second grammar to keep
in sync.
Like @lexical/markdown, the original syntax of a construct is preserved on
the Lexical nodes (the bullet character of a list, a code block's fence, and a
hard line break's style), so re-serializing produces minimally different
Markdown — * a/+ b bullets and ~~~ fences round-trip unchanged.
Configured through extensions
@lexical/mdast is set up exclusively through the Lexical extension
system, modeled on @lexical/html's DOMImportExtension. Each feature
extension ships the nodes it needs and contributes its import/export rules (and
the micromark/mdast extensions that tokenize them) to the coreMdastExtension registry:
CommonMark features:
| Extension | Ships | Adds |
|---|---|---|
MdastHeadingExtension |
HeadingNode |
ATX & setext headings |
MdastBlockquoteExtension |
QuoteNode |
block quotes |
MdastListExtension |
ListNode, ListItemNode |
ordered/unordered lists |
MdastCodeExtension |
CodeNode |
fenced & indented code |
MdastLinkExtension |
LinkNode |
links, <autolinks>, reference links |
MdastHorizontalRuleExtension |
HorizontalRuleNode |
thematic breaks (---) |
GFM features:
| Extension | Ships | Adds |
|---|---|---|
MdastStrikethroughExtension |
– | ~~strikethrough~~ |
MdastTaskListExtension |
– | task lists (- [x] …) |
MdastAutolinkLiteralExtension |
– | literal autolinks (bare https://… in prose) |
MdastTableExtension |
TableNode, … |
tables |
Behavior and convenience bundles:
| Extension | Adds |
|---|---|
MdastCommonMarkExtension |
bundle of the six CommonMark extensions |
MdastGfmExtension |
bundle of the four GFM extensions |
MdastRichTextExtension |
bundle of heading + blockquote |
MdastExtension |
core registry, Markdown import and export |
MdastShadowRootQuoteExtension |
opt-in: blockquotes as block containers (full-fidelity nested content) |
MdastHtmlExtension |
opt-in: raw HTML routed through the @lexical/html DOM import rules; HTML-encoded export via $exportViaDOM / rawHtmlBlock |
MdastShortcutsExtension |
streaming keyboard shortcuts |
Everything composes granularly and degrades gracefully: an editor with only
the extensions it wants imports unsupported constructs as their content
(a table becomes its cell text), and the typing shortcuts — driven by the
same registry — only fire for constructs the editor can represent (>
stays literal without MdastBlockquoteExtension).
MdastExtension owns the shared configuration and exposes both import and
export. Feature extensions depend on it automatically.
Usage
import {
$convertFromMarkdownString,
$convertToMarkdownString,
MdastCommonMarkExtension,
MdastExtension,
MdastGfmExtension,
MdastShortcutsExtension,
} from '@lexical/mdast';
import {buildEditorFromExtensions} from '@lexical/extension';
import {defineExtension} from 'lexical';
const editor = buildEditorFromExtensions(
defineExtension({
// CommonMark + GFM grammar, import + export (MdastExtension), and
// typing shortcuts. Swap bundles for individual feature extensions
// to trim what you don't need.
dependencies: [
MdastCommonMarkExtension,
MdastGfmExtension,
MdastExtension,
MdastShortcutsExtension,
],
name: '[root]',
}),
);
// Import / export run inside the editor; both are `$`-functions.
editor.update(() => {
$convertFromMarkdownString('# Hello *world*');
});
const markdown = editor.read(() => $convertToMarkdownString());The same API is available from the editor as$getExtensionOutput(MdastExtension).$convertFromMarkdownString(...)
and$getExtensionOutput(MdastExtension).$convertToMarkdownString(...).
$convertSelectionToMarkdownString(selection?) serializes only the
selected content (defaulting to the current selection): unselected
blocks and list items are skipped and partially selected text is
sliced to the selected range.
unified / remark interop
The mdast tree itself is part of the API, so editor content can flow
through the wider unified ecosystem — remark
plugins, remark-rehype for HTML rendering, tree diffing:
import {$convertFromMdast, $convertToMdast} from '@lexical/mdast';
// Editor -> mdast tree (before serialization).
const tree = editor.read(() => $convertToMdast());
// ... run remark plugins / transform the tree ...
// mdast tree -> editor.
editor.update(() => $convertFromMdast(tree));The *FromMarkdownString functions parse the source text themselves
(which is also what enables source-based syntax preservation, e.g.
keeping * vs - bullets); the *FromMdast functions take an
already-parsed tree, where no source text exists so syntax
preservation is skipped.
To convert Markdown into nodes without replacing the document —
e.g. to insert at the current selection —$generateNodesFromMarkdownString(markdown) (and its tree-taking
sibling $generateNodesFromMdast(tree)) returns a detached array of
block-level nodes and leaves the document and selection untouched:
import {$generateNodesFromMarkdownString} from '@lexical/mdast';
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertNodes($generateNodesFromMarkdownString('# Inserted'));
}
});Serialization options
Document-level mdast-util-to-markdown options (bullet, emphasis
marker, fence, ...) can be contributed like any other configuration —
scalar options in a toMarkdownExtensions entry apply document-wide
and override the package defaults:
import {MdastExtension} from '@lexical/mdast';
import {configExtension} from 'lexical';
// Serialize bullets as `+` and emphasis as `_`. Per-node syntax
// recorded on import (a list's bullet, a code block's fence, ...)
// still wins for those nodes' own output.
configExtension(MdastExtension, {
toMarkdownExtensions: [{bullet: '+', emphasis: '_'}],
});Raw HTML (`MdastHtmlExtension`)
Markdown passes raw HTML through — GitHub-style <details> blocks, inline<kbd> runs — and by default it imports as literal text. The opt-inMdastHtmlExtension routes it through the editor's @lexical/htmlDOMImportExtension rules instead, so any HTML the editor can already
import works from Markdown, and Markdown inside the construct keeps
working in both directions, the way it does on GitHub:
The *summary* line
The **body** blocks- Import: raw HTML block sequences and inline tag runs are reassembled
by tag balance (CommonMark splits blocks on blank lines), parsed withDOMParser, and dispatched through the DOM import rule registry.
Markdown between the tags is parsed with the document's own grammar and
substituted back in with the surrounding formatting context. Unclosed
tags — including the<p/<detailsprefixes typing passes through —
stay literal text. - Export: register
$exportViaDOMfor a node type and itsexportDOM
becomes the single source of truth for the Markdown encoding too: the
shell is rendered, the children channel and named slots (marked withdata-lexical-slot, which is stripped from the output) are substituted
with embedded Markdown, boolean attributes are normalized, and custom
element tags get their own lines where CommonMark requires it to
re-parse. For hand-written encodings,rawHtmlBlock(...parts)builds
the same kind of node from a template of raw tag strings, embedded
Markdown phrasing, and{flow}block runs. - Context states: the Markdown pipeline runs under the same
context-record mechanism as the@lexical/htmlimport/render pipelines.
The whole import walk runs withImportContextMarkdownset (so a DOM
rule can distinguish Markdown import from HTML paste); mdast import
handlers read ambient state with$getImportContextValueand layer
state for their subtree with$withImportContextaroundctx.importChildren— visible to nested handlers and to DOM-rule
sessions opened for raw HTML in that subtree, and to nothing outside
it. On export,RenderContextMarkdownExportletsexportDOMdiverge
per destination (Markdown vs the HTML clipboard), and selection exports
($convertSelectionToMarkdownString) run underRenderContextMarkdownSelectioncarrying the selection, so a
contributed to-markdown handler that appends end-of-document data can
scope its output to a clipboard copy.
A complete HTML-encoded construct is one DOM import rule (which then also
serves HTML paste) plus one export rule:
import {$exportViaDOM, MdastHtmlExtension, MdastExtension} from '@lexical/mdast';
import {defineImportRule, DOMImportExtension, sel} from '@lexical/html';
import {configExtension, defineExtension} from 'lexical';
export const MdastCollapsibleExtension = defineExtension({
name: 'collapsible-markdown',
nodes: [CollapsibleNode],
dependencies: [
MdastHtmlExtension,
configExtension(DOMImportExtension, {
// sel.tag('details') -> CollapsibleNode; serves Markdown and paste.
rules: [DetailsImportRule],
}),
configExtension(MdastExtension, {
// exportDOM is the single source of truth for the encoding.
exportRules: [{$export: $exportViaDOM, type: CollapsibleNode}],
}),
],
});The mdast-editor dev example
demonstrates the block path (a <details><summary> collapsible with a
named summary slot), the inline path (<kbd> keys), and inline HTML text
formats (<u>, <mark>, <sub>/<sup>, style="color: …" spans).
Custom mappings
Because extensions are the unit of configuration, you add or override behavior
by contributing rules to MdastExtension from your own extension:
import {MdastExtension} from '@lexical/mdast';
import {configExtension, defineExtension} from 'lexical';
export const MyMdastExtension = defineExtension({
name: 'my-mdast',
nodes: [MyNode],
dependencies: [
configExtension(MdastExtension, {
importRules: [{type: 'myMdastType', $import: $importMyNode}],
exportRules: [{type: MyNode, $export: $exportMyNode}],
micromarkExtensions: [myMicromarkExtension()],
mdastExtensions: [myMdastExtension()],
toMarkdownExtensions: [myToMarkdownExtension()],
}),
],
});Export rule type accepts a Lexical type string or node class: 'text' andTextNode are equivalent. Rules also apply to subclasses, so a text rule
handles TabNode and custom text nodes unless a more specific rule exists.
The nearest matching ancestor runs first, regardless of contribution order. For
multiple rules targeting the same type (including a mix of strings and classes),
array order determines priority; contributions merged later are prepended.
Ancestor classes do not need to be registered in the editor themselves. Classes
must have their own node type; abstract classes without one (such as ElementNode)
are rejected.
Rules are resolved once when the editor is built.
Handlers can delegate with context.next(), which converts the same node
using the next handler and returns an array of output nodes. An export chain
runs lower-priority rules for the same type before moving to its ancestors,
from nearest to farthest. When no handlers remain, it uses the generic export
fallback. A handler can return the delegated result or modify it. Formatting
such as bold and italic wraps text in child nodes, so this uppercase example
walks the returned tree, including inline code:
import {MdastExtension, type MdastNode} from '@lexical/mdast';
import {configExtension, TextNode} from 'lexical';
function uppercaseText(nodes: readonly MdastNode[]): void {
for (const node of nodes) {
if (node.type === 'text' || node.type === 'inlineCode') {
node.value = node.value.toUpperCase();
} else if ('children' in node) {
uppercaseText(node.children);
}
}
}
configExtension(MdastExtension, {
exportRules: [{
type: TextNode,
$export: (_node, context) => {
const output = context.next();
uppercaseText(output);
return output;
},
}],
});Plain formatted text returned by text middleware is merged into adjacent text
runs so shared and overlapping formats serialize correctly. Output containing
extra fields such as data, or custom structure, is preserved as-is instead
of merged. Such middleware must ensure its output serializes correctly on its
own; adjacent bold nodes, for example, can produce **a****b**.
Import rules use mdast type strings: mdast nodes are plain objects,
without a Lexical node class hierarchy. Their context.next() runs the next
handler for the same type in contribution order, eventually reaching the
generic import fallback.
| Handler result | Import | Export |
|---|---|---|
context.next() |
Runs the next handler for the same mdast type, or the generic import fallback when none remain. | Runs the next same-type or ancestor handler, or the generic export fallback when none remain. |
null |
Omits the node and its children. | Uses the default export directly, without trying remaining handlers. |
[] |
Omits the node and its children. | Omits the node. |
Overview (`packages/lexical-offset/README.md`)
`@lexical/offset`
This package contains selection offset helpers for Lexical.
Overview (`packages/lexical-overflow/README.md`)
`@lexical/overflow`
This package contains selection overflow helpers and nodes for Lexical.
Overview (`packages/lexical-plain-text/README.md`)
`@lexical/plain-text`
This package provides a starting point for Lexical users by registering listeners for a set of basic commands that cover simple text-editing behavior such as entering text, deleting characters, copy + paste, or changing the selection with arrow keys.
You can use this package as a starting point, and then add additional command listeners to customize the functionality of your editor. If you want to add rich-text features, such as headings, blockquotes, or formatted text, you may want to consider using @lexical/rich-text instead.
Overview (`packages/lexical-react/README.md`)
`@lexical/react`
This package provides a set of components and hooks for Lexical that allow for text editing in React applications.
Getting started
Install lexical and @lexical/react:
npm install --save lexical @lexical/reactBelow is an example of a basic plain text editor using lexical and @lexical/react (try it yourself).
/* Detailed source-code truncated for AI context efficiency. */Overview (`packages/lexical-rich-text/README.md`)
`@lexical/rich-text`
This package provides a starting point for Lexical users by registering listeners for a set of basic commands that cover simple text-editing behavior such as entering text, deleting characters, copy + paste, or changing the selection with arrow keys. It also provides default behavior for rich text features, such as headings, formatted, text and blockquotes.
You can use this package as a starting point, and then add additional command listeners to customize the functionality of your editor. If you don't want or need rich text functionality, you may want to consider using @lexical/plain-text instead.
Overview (`packages/lexical-selection/README.md`)
`@lexical/selection`
This package contains selection helpers for Lexical.
Overview (`packages/lexical-table/README.md`)
`@lexical/table`
This package contains the functionality for the Tables feature of Lexical.
Lexical Table Plugin
A plugin for handling tables in Lexical.
Installation
npm install @lexical/table @lexical/reactUsage
See the react-table example
for a minimal example that uses this package and the
TablePlugin
from @lexical/react/LexicalTablePlugin
Features
Tables
- Create and edit tables with customizable rows and columns
- Support for table headers
- Cell selection and navigation
- Copy and paste support
Limitations
Nested Tables
Nested tables (tables within table cells) are not supported in the editor. The following behaviors are enforced:
- When attempting to paste a table inside an existing table cell, the paste operation is blocked.
- The editor actively prevents the creation of nested tables through the UI or programmatically.
Note: When pasting HTML content with nested tables, the nested content will be removed by default. Make sure to implement appropriate importDOM handling if you need to preserve this content in some form.
This approach allows you to:
- Detect nested tables in the imported HTML
- Extract their content before it gets removed
- Preserve the content in a format that works for your use case
Choose an approach that best fits your needs:
- Flatten nested tables into a single table
- Convert nested tables to a different format (e.g., lists or paragraphs)
- Store nested content as metadata for future processing
Overview (`packages/lexical-tailwind/README.md`)
`@lexical/tailwind`
EXPERIMENTAL
This package contains an extension to use Tailwind for the editor theme
Overview (`packages/lexical-text/README.md`)
`@lexical/text`
This package contains utilities and helpers for handling Lexical text.
Overview (`packages/lexical-utils/README.md`)
`@lexical/utils`
This package contains misc utilities for Lexical.
Overview (`packages/lexical-website/README.md`)
Website
This website is built using Docusaurus 2, a modern static website generator.
Installation
$ yarnLocal Development
$ yarn startThis command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
Build
$ yarn buildThis command generates static content into the build directory and can be served using any static contents hosting service.
Deployment
Using SSH:
$ USE_SSH=true yarn deployNot using SSH:
$ GIT_USER=<Your GitHub username> yarn deployIf you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the gh-pages branch.
Overview (`packages/lexical-yjs/README.md`)
`@lexical/yjs`
This package provides a set of bindings for Y.js that allow for collaborative editing with Lexical.
Overview (`packages/lexical/README.md`)
`lexical`
Lexical is an extensible JavaScript web text-editor framework with an emphasis on reliability, accessibility, and performance. Lexical aims to provide a best-in-class developer experience, so you can easily prototype and build features with confidence. Combined with a highly extensible architecture, Lexical allows developers to create unique text editing experiences that scale in size and functionality.
The core of Lexical is a dependency-free text editor engine that allows for powerful, simple and complex,
editor implementations to be built on top. Lexical's engine provides three main parts:
- editor instances that each attach to a single content editable element.
- a set of editor states that represent the current and pending states of the editor at any given time.
- a DOM reconciler that takes a set of editor states, diffs the changes, and updates the DOM according to their state.
By design, the core of Lexical tries to be as minimal as possible.
Lexical doesn't directly concern itself with things that monolithic editors tend to do, such as UI components, toolbars, rich-text features or markdown. Instead,
those features are added to an editor as extensions from optional packages, which are used as and when they're needed. This ensures great extensibility and keeps code sizes
to a minimum, so apps only pay the cost for what they actually import.
For React apps, the optional @lexical/react package provides React bindings, including LexicalExtensionComposer, which builds an editor from extensions and renders it.
Usage
The lexical package contains the core Lexical engine and nodes, and the extension system that the other packages build on. Most applications build an editor with buildEditorFromExtensions() from @lexical/extension (or with @lexical/react), and add features such as rich text and history with the extensions from other @lexical/* packages.
Working with Lexical
This section covers how to use Lexical independently of any framework or library. For React, see Getting Started with React.
Creating an editor and using it
When you work with Lexical, you normally work with a single editor instance. An editor instance can be thought of as the one responsible
for wiring up an EditorState with the DOM. You build one from extensions: each extension bundles the nodes, configuration, commands and listeners for a feature, together with any extensions it depends on.
import {buildEditorFromExtensions} from '@lexical/extension';
import {HistoryExtension} from '@lexical/history';
import {RichTextExtension} from '@lexical/rich-text';
import {defineExtension} from 'lexical';
const editor = buildEditorFromExtensions(
defineExtension({
dependencies: [RichTextExtension, HistoryExtension],
name: '@my-app/editor',
namespace: 'MyEditor',
theme: {
// ...
},
}),
);Once you have an editor instance, when ready, you can associate the editor instance with a content editable <div> element in your document:
const contentEditableElement = document.getElementById('editor');
editor.setRootElement(contentEditableElement);If you want to clear the editor instance from the element, you can pass null. Alternatively, you can switch to another element if need be,
just pass an alternative element reference to setRootElement(). When you are done with the editor, call editor.dispose().
An editor that never gets a root element does no DOM work at all, so the same code can run on a server or in tests.
Understanding the Editor State
With Lexical, the source of truth is not the DOM, but rather an underlying state model
that Lexical maintains and associates with an editor instance. You can get the latest
editor state from an editor by calling editor.getEditorState().
Editor states have two phases:
- During an update they can be thought of as "mutable". See "Updating an editor" below to
mutate an editor state. - After an update, the editor state is then locked and deemed immutable from there on. This
editor state can therefore be thought of as a "snapshot".
Editor states contain two core things:
- The editor node tree (starting from the root node).
- The editor selection (which can be null).
Editor states are serializable to JSON, and the editor instance provides a useful method
to deserialize stringified editor states.
const stringifiedEditorState = JSON.stringify(editor.getEditorState());
const newEditorState = editor.parseEditorState(stringifiedEditorState);
editor.setEditorState(newEditorState);To start an editor with some content, give its extension an $initialEditorState: a stringified editor state, or a function that builds the content inside an update.
Updating an editor
There are a few ways to update an editor instance:
- Trigger an update with
editor.update() - Setting the editor state via
editor.setEditorState() - Applying a change as part of an existing update via
editor.registerNodeTransform() - Using a command listener with
editor.registerCommand(EXAMPLE_COMMAND, () => {...}, priority)
The most common way to update the editor is to use editor.update(). Calling this function
requires a function to be passed in that will provide access to mutate the underlying
editor state. When starting a fresh update, the current editor state is cloned and
used as the starting point. From a technical perspective, this means that Lexical leverages a technique
called double-buffering during updates. There's an editor state to represent what is current on
the screen, and another work-in-progress editor state that represents future changes.
Creating an update is typically an async process that allows Lexical to batch multiple updates together in
a single update, improving performance. When Lexical is ready to commit the update to
the DOM, the underlying mutations and changes in the update will form a new immutable
editor state. Calling editor.getEditorState() will then return the latest editor state
based on the changes from the update.
Here's an example of how you can update an editor instance:
import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
// Inside the `editor.update` you can use special $ prefixed helper functions.
// These functions cannot be used outside the closure, and will error if you try.
// (If you're familiar with React, you can imagine these to be a bit like using a hook
// outside of a React function component).
editor.update(() => {
// Get the RootNode from the EditorState
const root = $getRoot();
// Create a new ParagraphNode
const paragraphNode = $createParagraphNode();
// Create a new TextNode
const textNode = $createTextNode('Hello world');
// Append the text node to the paragraph
paragraphNode.append(textNode);
// Finally, append the paragraph to the root
root.append(paragraphNode);
});To read the editor state without changing it, use editor.read(), which also accepts the $ prefixed helper functions:
const text = editor.read(() => $getRoot().getTextContent());If you want to know when the editor updates so you can react to the changes, you can add an update
listener. The usual place to register one is the register function of your own extension, which is
called when the editor is built and cleans up when it is disposed:
import {$getRoot, defineExtension} from 'lexical';
const WordCountExtension = defineExtension({
name: '@my-app/WordCount',
register: (editor) =>
editor.registerUpdateListener(({editorState}) => {
// The latest EditorState can be found as `editorState`.
// To read its contents, pass `{editor}` so the $ prefixed helper
// functions can find the editor too.
editorState.read(
() => {
const words = $getRoot().getTextContent().split(/\s+/);
console.log(words.filter(Boolean).length);
},
{editor},
);
}),
});Add WordCountExtension to your editor's dependencies alongside the extensions it already uses.
To learn more, see the Lexical documentation.
Overview (`.github/actions/internal-registry/README.md`)
Internal registry actions
Two composite actions that let a GitHub Actions job talk to the internal npm
registry without a static token. The job's own OIDC identity is exchanged for a
short-lived credential, per run.
| action | what it does |
|---|---|
auth |
Mints the credential. Optionally writes it into the npm user config so later install / publish steps just work. |
dependency-check |
Asserts every version pinned in a lockfile is actually served by the registry. |
Both are versioned with their own tags (internal-registry-auth-v1,internal-registry-dependency-check-v2). Pin a tag — do not track main.
Using it from another repository
They are public actions, so any repository in any organization can use them.
Read-only check:
jobs:
dependency-check:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required, and it must be set on the JOB
steps:
- uses: actions/checkout@v7
- id: auth
uses: facebook/lexical/.github/actions/internal-registry/auth@internal-registry-auth-v1
with:
write-npmrc: 'false'
on-missing-oidc: skip
- uses: facebook/lexical/.github/actions/internal-registry/dependency-check@internal-registry-dependency-check-v2
with:
token: ${{ steps.auth.outputs.token }}Installing or publishing scoped packages:
- uses: actions/setup-node@v6 # BEFORE auth: setup-node rewrites the npm config
with:
node-version: '24'
- uses: facebook/lexical/.github/actions/internal-registry/auth@internal-registry-auth-v1
with:
scopes: '@acme @acme-ui'
- run: npm publishThings that will bite you
permissions: id-token: writemust be on the job. A job-levelpermissionsblock replaces the workflow-level one rather than merging
with it, so setting it only at the workflow level leaves the job with no
OIDC identity.- Run
actions/setup-nodebeforeauth, not after. Whensetup-nodeis
given aregistry-urlit rewrites the npm user config, which would discard
whatauthwrote. authwrites user config, not a project-local.npmrc. npm does not
walk up from the directory it publishes from, so a repo-root.npmrcis
invisible tonpm publishrun in a subdirectory. This is the usual cause of
a confusingENEEDAUTHwhen the credential is demonstrably present.- The two actions compose in the workflow; they do not nest. A local
uses: ./...inside a composite action resolves against the caller's
workspace, not the repository the action came from, so a remote composite
action cannot reference a sibling. This is whydependency-checktakes atokeninput instead of callingauthitself. - Fork pull requests have no OIDC identity. Use
on-missing-oidc: skipand
gate on a requiredmerge_grouprun.
Diagnosing a rejected credential
auth logs the OIDC subject it minted for:
Credential minted for subject: repo:my-org/my-repo:ref:refs/heads/mainThat string is what the registry authorizes against, so quote it when asking
for access. It is not a secret; the credential itself is masked. Note that some
repositories are configured to emit an immutable subject that embeds numeric
IDs (repo:my-org@123/my-repo@456:...) — check with:
gh api repos/<owner>/<repo>/actions/oidc/customization/subOverview (`packages/lexical-eslint-plugin/__tests__/integration/README.md`)
ESLint Plugin Integration Tests
This directory contains integration tests that verify @lexical/eslint-plugin
works correctly with every supported ESLint version.
Test Coverage
The integration tests verify:
- ESLint 9.x with a flat
eslint.config.jsconfiguration - ESLint 10.x with the same configuration
Running the Tests
From the repository root:
pnpm run test-eslint-integrationHow It Works
The test script uses pnpm dlx to run different ESLint versions without
modifying package.json or pnpm-lock.yaml. This ensures:
- No dependency conflicts
- Clean testing environment
- Multiple ESLint versions can be tested in the same run
Test Fixtures
Flat Config
Located in fixtures/flat-config/:
eslint.config.js- Flat ESLint configuration usingconfigs.recommendedvalid.js- Code that should pass lintinginvalid.js- Code that should trigger@lexical/rules-of-lexicalerrors
Expected Behavior
Valid Code Examples
These should pass linting:
- Functions with
$prefix calling other$functions - Code inside
editor.update()callbacks calling$functions - Class methods calling
$functions
Invalid Code Examples
These should fail linting with @lexical/rules-of-lexical error:
- Functions without
$prefix calling$functions directly
Test Output
The test script provides colored output:
- ✓ Green = Test passed
- ✗ Red = Test failed
- Summary at the end with total/passed/failed counts
Overview (`packages/lexical-website/plugins/example-source/README.md`)
Example source in documentation
Getting Started guides import runnable example code as strings and render it with Docusaurus CodeBlock. Edit the example source to update these blocks; do not copy the code into MDX.
Mark each fragment with a stable name and a comment identifying the guide that reads it:
// [docs:my-extension] Read directly by the Creating an Extension guide.
export const MyExtension = defineExtension({
name: 'MyExtension',
});
// [/docs:my-extension]Then import that region in the guide:
import CodeBlock from '@theme/CodeBlock';
import MyExtensionSource from '!!example-source?region=my-extension!@site/../../examples/my-example/src/MyExtension.ts';
<CodeBlock language="ts" title="examples/my-example/src/MyExtension.ts">
{MyExtensionSource}
</CodeBlock>The local example-source loader is registered in docusaurus.config.ts. !! bypasses the normal source loaders so the example is displayed as text, not executed in the documentation page. The bundler tracks the imported file, so edits also refresh the documentation during development.
Use named markers, never line ranges. Formatting changes and unrelated insertions must not change which code the guide includes. Each imported region must have exactly one opening and one closing marker, in that order, and contain nonempty source. A missing, duplicated, reversed, or empty region fails the documentation build with the file path and region name.
Regions may be nested. All marker comments are removed from the displayed code, and common indentation is removed. CSS uses /* [docs:name] */ and /* [/docs:name] */; HTML uses and.
The loader tests run as part of test-examples in CI. Small illustrative snippets that explain an alternative usage may still be written in MDX; code taken from a runnable example should use this loader.
Overview (`packages/lexical-website/src/data/README.md`)
Team Data
This directory contains dynamically generated data for the Lexical team page.
team.json
The team.json file contains information about:
- Core Team: Active maintainers with PR review access who have contributed in the last 12 months
- Core Team Emeriti: Previous core team members who haven't been active recently but made significant contributions
- Distinguished Contributors: Community members with substantial contributions (11+ total contributions)
Updating Team Data
The team data is automatically generated from GitHub contributor data using the generate-team-data.js script.
To update the team data:
pnpm run update-team-dataThis script:
- Fetches all contributors from the GitHub API
- Checks recent activity (last 12 months) for tracked team members
- Fetches user profile information (name, Twitter, company)
- Categorizes contributors based on:
- Whether they have PR review access
- Recent commit activity
- Total contribution count
- Generates the
team.jsonfile with sorted team members
Configuration
The team categorization logic is configured in scripts/generate-team-data.mjs
When to Update
Update the team data:
- Occasionally to reflect recent contributions
- When adding new core team members
- When team members change roles or organizations
Requirements
- GitHub CLI (
gh) must be installed and authenticated - Internet connection to access GitHub API
- Node.js 18+
Overview (`packages/lexical/src/__bench__/README.md`)
Lexical Benchmarks
Performance benchmarks for the Lexical core. Run via pnpm bench from the
repo root, or scoped to a single project with --project "bench (bench)" (data
structure microbenches, node env) or --project "bench-dom (bench)" (real-editor
benches, jsdom env).
pnpm bench # all bench projects
pnpm vitest bench --project "bench (bench)" # microbenches only
pnpm vitest bench --project "bench-dom (bench)" # editor cycle benches only
pnpm vitest bench --project "bench (bench)" nodeMap # filter by file substringTwo projects, why?
Microbenches that exercise pure data structures (Map vs GenMap, etc.)
run in bench (node env, no DOM) — fastest startup, cleanest numbers.
Benches that exercise a real Lexical editor (which needs a DOM) run inbench-dom (jsdom env). Files in __bench__/dom/** are picked up only
by this project. The two projects share the same pnpm bench entry but
otherwise stay isolated to keep microbench results uncontaminated by
jsdom setup cost.
What benches live here
| File | Project | Measures |
|---|---|---|
nodeMap.bench.ts |
bench |
Map vs GenMap on clone / typing / paste / iteration / get |
getWritable.bench.ts |
bench |
headless writes, replacement, and selection formatting at fixed document sizes |
dom/editorCycle.bench.ts |
bench-dom |
real editor.update cycle cost on a jsdom-backed editor |
dom/editorOperations.bench.ts |
bench-dom |
editor operations: split, format, delete range, paste, select-all |
Helpers shared across files live in _utils.ts (microbench) anddom/_utils.ts (real-editor). Use them when you can; extract new helpers
there if your bench file grows beyond a single workload.
For writable-node changes, compare production bundles with the same current
benchmark on every revision:
node scripts/bench-get-writable.mjs [other-refs...] > results.jsonlThe Vitest writable-node benchmark checks both alternating edits during setup
and the final document after timing, so a no-op cannot hide behind the parity
of the last iteration.
The runner applies the shared package-build Babel options (including the
repository's Browserslist targets and production error transform) before
esbuild bundles JavaScript with target: 'esnext'. Native class fields are
preserved. The shared Terser settings use ES2021 and two compression passes;
the runner additionally verifies that all development constants are eliminated
before importing the bundle. Every revision uses the same current build options,
so the comparison measures source changes under that configuration, not the
isolated effect of changing build settings. This is a headless source bundle,
not the published package layout or the compiler annotation pipeline.
The runner also includes the working tree. It verifies each workload before
timing, rotates revision order across nine samples, and reports median
microseconds per update plus the individual samples. This reduces timing
drift between separate runs. Measurements cover headless updates without DOM
reconciliation; small differences still need to be treated as noise.
Set LEXICAL_BENCH_FILTER to a regular expression over the workload name andLEXICAL_BENCH_SAMPLES to a positive integer for longer, focused comparisons.
When to add a bench
Add a bench when you are landing a perf change and want to:
- Establish a baseline so future regressions are detectable.
- Justify a non-obvious algorithmic choice with measurements.
- Compare implementation strategies (the
nodeMap.bench.tsworkflow).
Skip a bench for changes whose perf impact is obvious or unmeasurable in
isolation.
Conventions
File naming
*.bench.tsdirectly under__bench__/for node-env microbenches.*.bench.tsunder__bench__/dom/for jsdom-env benches.
Structure — sweep across realistic sizes; one test per scenario;
one bench per implementation under test. Vitest 5 provides bench through
the test context. Call .run() for a single benchmark or bench.compare()
for multiple implementations.
import {test} from 'vitest';
import {buildMap, type FakeNode} from './_utils';
import {MyImpl} from '../MyImpl';
const SIZES = [100, 1000, 10000, 100000] as const;
for (const size of SIZES) {
test(`size=${size} :: <scenario name>`, async ({bench}) => {
let oldImpl: Map<string, FakeNode>;
let newImpl: MyImpl<string, FakeNode>;
await bench.compare(
bench(
'old',
{
beforeAll: () => {
oldImpl = buildMap(size);
},
},
() => {
// operation under test using oldImpl
},
),
bench(
'new',
{
beforeAll: () => {
newImpl = MyImpl.fromMap(buildMap(size));
},
},
() => {
// operation under test using newImpl
},
),
);
});
}Comparison pattern — pass both implementations to bench.compare() in
one test. Vitest reports their relative performance in a comparison table.
Setup state — use the beforeAll benchmark option to initialize state
before warmup and again before measured iterations. It does not run for
every iteration. Use beforeEach and afterEach benchmark options for
per-iteration work outside the timed body. Timing options such as time,iterations, and warmupTime go in .run(options) or the final argument
to bench.compare(). Mutations made by the benchmark body accumulate across
iterations unless a hook resets the state.
DCE prevention — V8 may eliminate calls whose return values are
unused. Each bench file should declare a module-level scalar sink and
assign into it from the timed body so the call can't be elided:
let _benchSink: unknown;
test('map lookup', async ({bench}) => {
await bench('get', () => {
_benchSink = map.get(someKey);
}).run();
});For loops, accumulate into a local and assign the local once at the end:
test('map iteration', async ({bench}) => {
await bench('iterate', () => {
let count = 0;
for (const _ of map) count++;
_benchSink = count;
}).run();
});A scalar assignment (rather than an array push) avoids unbounded memory
growth at high iteration counts — e.g. size=100000 get can run millions
of iterations per bench cycle.
Reading results
Vitest reports throughput (ops/sec), latency, percentiles, and relative
performance in each comparison table. Compare implementations within the
same table; mean latency is useful as an absolute number, while high
percentiles indicate tail behavior under GC or compaction.
Sample size and warmup are managed by Vitest. The default heuristics are
fine for stable comparisons between two impls on the same machine; for
absolute numbers you intend to publish, run multiple times and report the
median.
Limits
- Numbers are machine-dependent. Compare runs on the same machine and
the same load. Don't compare numbers across hardware. - jsdom DOM ops are slower than real browsers. Treat
bench-domnumbers
as relative comparisons, not absolute production estimates. - Benchmark timings do not run in CI. Benchmark types are checked by
pnpm run tsc-test, which is part ofpnpm run ci-check.
Packages Lexical Website Docs Intro (`packages/lexical-website/docs/intro.md`)
sidebar_position: 1
Introduction
Lexical is an extensible text editor framework for the web, built for
reliability, accessibility, and performance. It gives you a small,
dependency-free core and a set of optional packages that you compose into the
editor you need, from a plain-text input with mentions to a collaborative
rich-text document editor.
Lexical attaches to a contenteditable element and keeps its own model of the
document. You read and change that model with Lexical's APIs, and Lexical
takes care of keeping the DOM, the selection, and the browser's manycontenteditable quirks in sync. Most code never touches the DOM directly;
the main exception is custom nodes, which define how they render.
What can you build? {#what-can-be-built-with-lexical}
- Plain-text inputs that need more than a
<textarea>: mentions, hashtags,
links, custom emoji. - Rich-text editors for comments, posts, and messages.
- Full document editors with tables, lists, code blocks, and images, for a CMS
or a notes app. - Real-time collaborative editing with shared content and remote cursors,
using the Yjs integration.
Lexical supplies the editing infrastructure. Your application supplies the
layout, toolbars, menus, styling, and storage.
Lexical is an open-source community project. It began at Meta, and its
contributors today include Meta engineers alongside many volunteers and
developers from other companies. It powers text editing in Meta's web products
and at Bloomberg, and is also the editor behind
Payload CMS,
Supabase,
Proton Docs,
MDXEditor, Sveltia CMS,
Dify, RAGFlow,
DeepSeek Harness,
Paperclip, and Lexxy. To see
what it can do, try the playground.
How it fits together
The lexical package is the core: the editor, the editor state, the base
node types, selection, commands, and the DOM reconciler. Everything else is an
optional package built on top of it, so an application only includes the
features it uses. The core is framework-agnostic, and @lexical/react
provides React bindings.
flowchart TB
accTitle: How Lexical's packages fit together
accDescr: Your application uses optional packages such as rich text, history, lists, tables, and React bindings. Both the application and those packages build on the lexical core, which contains the editor, the editor state, commands, and the DOM reconciler.
app["Your application"]
subgraph features["Optional packages"]
rich["@lexical/rich-text@lexical/plain-text"] ~~~ more["@lexical/history<br/>@lexical/list<br/>@lexical/table<br/>…"] ~~~ bindings["@lexical/react"]
end
subgraph core["lexical (core)"]
editor["Editor"] ~~~ state["EditorState<br/>(node tree + selection)"] ~~~ commands["Commands"] ~~~ reconciler["DOM reconciler"]
end
app --> features
app --> core
features --> coreFeatures are added to an editor as extensions. An
extension bundles everything a feature needs (its nodes, configuration,
commands, and listeners, plus any extensions it depends on) so it can be
added in one place:
import {buildEditorFromExtensions} from '@lexical/extension';
import {HistoryExtension} from '@lexical/history';
import {RichTextExtension} from '@lexical/rich-text';
import {defineExtension} from 'lexical';
const editor = buildEditorFromExtensions(
defineExtension({
dependencies: [RichTextExtension, HistoryExtension],
name: '@my-app/editor',
namespace: 'my-app',
}),
);
editor.setRootElement(document.getElementById('editor'));In React, LexicalExtensionComposer does the same
job. The Quick Start and
React guide walk through a complete setup.
Core concepts {#lexicals-design}
These are the ideas the rest of the documentation builds on. Each one links to
a page with the details.
Editor {#editor-instances}
The editor wires everything together. It owns the current editor state,
attaches to a root DOM element, and is where you register nodes, listeners,
transforms, and commands. You usually create it withbuildEditorFromExtensions or through the React bindings rather than callingcreateEditor() yourself.
Editor state {#editor-states}
An editor state is an immutable snapshot of the
document: a tree of nodes under a single RootNode,
plus a selection. The editor state, not the DOM, is
the source of truth. Its toJSON() output is how you save and restore content.
A DOM-like document tree {#document-model}
Lexical's node tree is shaped like the HTML it renders. A paragraph contains
its text, and a link is an element that contains the text it wraps. See
Document Model, and
Compared with ProseMirror for how
this differs from a mark-based editor.
Reading and updating editor state {#reading-and-updating-editor-state}
All reads and writes happen inside a synchronous callback, such aseditor.update(fn) or editor.read('force-commit', fn). Functions whose
names start with $, such as $getRoot(), only work inside those callbacks,
in the same way React Hooks only work while a component renders. See
Editor State.
Updates and the DOM reconciler {#dom-reconciler}
Updates change a pending copy of the editor state. Updates in the same tick
are batched, and when the batch commits, the reconciler patches only the DOM
that belongs to changed nodes. Lexical also watches for DOM changes made
outside it and keeps or reverts them. Updates walks
through each phase, with an interactive example.
Commands, transforms, and listeners {#listeners-node-transforms-and-commands}
Most editor behavior is built from three kinds of hooks.
Commands carry user input and your own actions to
handlers in priority order. Node transforms run
during an update to keep the document in shape.
Listeners react after an update is committed.
Serialization and running on a server
Content converts to and from JSON, HTML, and Markdown; see
Serialization. An editor with no root
element does no DOM work at all, so the same code can run on a server or in
tests; see Running Without a Browser.
Get started
- Quick Start builds an editor without a
framework, and Getting Started with React does
the same in React. - Lexical Extensions and
Included Extensions cover how to add
features. - The playground shows many features working
together.
Packages Lexical Website Docs Extensions Intro (`packages/lexical-website/docs/extensions/intro.md`)
Lexical Extensions
Extensions are a new convention to add configuration and behavior to a Lexical
editor in a modular and framework-independent manner. It is easier to use and
more powerful than the React plug-in scheme that came before, and is framework
agnostic.
See Included Extensions for the list of extensions
that come with Lexical and its associated packages.
Use Case
Lexical Extensions allow complex features to be added to your editor
in a single place. If you want to have an editor that supports check
lists, you simply add the CheckListExtension to your dependencies.
All of its dependent configuration, registration, and dependencies on
other extensions is encapsulated. Extensions also work the same way
with or without React, so you don't generally need separate
documentation and exports to support both.
All of the complexity for how and when things need to happen is
moved to buildEditorFromExtensions and the definitions of the
extensions themselves.
In contrast, using checklists without Lexical Extensions requiresListNode and ListItemNode to be configured, and both the ListPlugin andCheckListPlugin to be registered exactly one time each (or if not using
React: registerList and registerCheckList).
Customizing a Lexical editor without Lexical Extensions is done in two phases:
- Configuration before the editor is constructed
(registered nodes, node replacement, theme, etc.) - Registration after the editor is constructed
(listeners, transforms, commands, react plug-ins, etc.)
In many cases, adding a feature to the editor requires both configuration
and registration. These typically happen at very different places in the code,
possibly even different files, and there are no transitive dependencies. It's
up to you to make sure that every required snippet of configuration and
registration code is included exactly once in the right place. When some dependent
configuration or registration is missing (or runs twice!), at best you get a runtime
error, but often it will partially work which is tricky to debug.
Another compelling use case for extensions is to provide functionality
from one extension to another extension (e.g. collaboration with yjs),
or have one extension enhance the capabilities of another (e.g. markdown
import/export, adding options to menus or toolbars, etc.).
This is very awkward and error-prone with legacy plug-ins since there is no
established pattern for them to depend on each other, and providing this
sort of functionality with React requires a strict hierarchy of context
providers. Any extension can provide output, which is equivalent to having
extension-specific context scoped to an editor.
Core API Overview
The primary use case for extensions is to build an editor from a collection of
extensions. Any configuration or behavior specific to your editor should be
provided as an extension with
defineExtension or as an override
of some dependency's configuration with
configExtension. This is done
with either:
- buildEditorFromExtensions - when not using React
- LexicalExtensionComposer - when using React
See Defining Extensions for a detailed guide fordefineExtension.
The following functions are included in the core
lexical module to ensure that defining
extensions is lightweight and requires no dependency on the runtime
features of @lexical/extension.
These functions are primarily for type inference with very minimal
implementations.
- defineExtension - define an extension
- configExtension - provide configuration overrides for an extension
- declarePeerDependency - declare an indirect dependency on another extension by name
- safeCast - a convenience for TypeScript casting, with more safety than
as. Similar to using a variable with a declared type. - shallowMergeConfig - the default configuration merge strategy
Advanced features
- Signals - Can be used for extensions to change their configuration or behavior at runtime, such as dynamically enabling or disabling an extension. This is also used to facilitate some performance optimizations around subscriptions and listeners.
- Peer Dependencies - Used to declare indirect or optional dependencies between extensions, this can be used to enable some functionality or configuration only when some other extension is available. For example, an Emoji extension may declare a specific Markdown serialization for EmojiNode, but only if the Markdown extension is present.
Packages Lexical Website Docs React Index (`packages/lexical-website/docs/react/index.md`)
sidebar_label: 'Introduction'
Lexical + React
To make it easier for React users to implement rich-text editors, Lexical exposes a set of abstractions that aim to cover a wide range of use cases out-of-the-box.
Supported Versions
React 18+ (tested with React 18 and React 19)
Packages Lexical Website Docs Design (`packages/lexical-website/docs/design.md`)
Lexical's Design
Note: this document is still a work-in-progress and things are likely still in flux.
Lexical was built from the ground up to be a lean and extensible text-editing framework.
You can think of Lexical as more of a text editing engine, rather than a monolithic text
editor that provides everything out-of-the-box. It might help to think of Lexical as
a framework like React – where React provides some hooks and a reconciler, and then you
build the things you need on top, rather than have them included by default.
The main concept of Lexical is the editor state. Lexical uses a double-buffering technique
to ensure consistency and reliability. There are never more than two editor states in play.
The "current" editor state represents what you can visibly see on screen and the "pending"
editor state is what is currently being constructed to be shown in the future. Once the
"pending" editor state is ready, it swaps and becomes the new "current" editor state.
Lexical's core concerns itself with four main concerns:
- Updates: the act of making changes to editor state
- Node Transforms: the process of acting on ongoing updates
- Reconciliation: the process of patching the DOM with the latest editor state
- Listening/Commands: the process of reacting to changes that occur internally
Additionally, Lexical uses DOM mutation observers to ensure that any outside changes to
the editor DOM element are either reverted back to Lexical's current editor state, or are
communicated as intents that cause further updates to the editor state (text changes).
This separation was done to ensure developers have a way of applying custom logic with the
implementation and framework of their choice. This makes it possible to use Lexical with
React, or any other JavaScript web framework/library. This also makes it easier to tackle
problems around collaboration and undo/redo, where you might have an alternative model involved.
Instead of hoping for the text editor to support what you want, you can work with the editor
to make what you want.
This design can make getting started a bit more complex in certain cases, but as we've been
able to demonstrate with our @lexical/react plugins and hooks – you can get started, in the
form of an out-of-the-box experience.
Packages Lexical Website Docs Error (`packages/lexical-website/docs/error.md`)
id: error title: Error Code
import ErrorCodePage from '@site/src/components/ErrorCodePage';
Error Code
Packages Lexical Website Docs Faq (`packages/lexical-website/docs/faq.md`)
sidebar_position: 7
FAQ
Why does Lexical use the `$` prefix in the name of many of the functions?
Originally, Lexical didn't have $ functions, instead these functions were provided to you through callback params:
// Mid 2020 API
editor.update((viewState) => {
const getRoot = viewState.getRoot();
..
});
editor.addTextTransform((viewState) => {
const getRoot = viewState.getRoot();
..
});Internally, this approach raised some negative feedback:
- The
viewStateterminology was confusing. It wasn't really aviewState, more like a toolkit to manipulate theEditorState - For complex updates and transforms devs had to carry params around on many layers
This is when we decided to leverage the "lexical" scope instead to perform EditorState manipulation, and the $ represents just that.
editor.update(() => ...);
editor.registerNodeTransform(FooNode, () => ...);
editor.getEditorState().read(...);If you've used React Hooks before, you can think of $ functions as being something that follows a similar pattern. These are functions that show their intent as to where they can or cannot be used. This makes it possible for a developer to create their own functions that give the same signal, by simply prefixing the function with the dollar.
Internally, we've found this scales really well and developers get to grips with it in almost no time at all.
When does reconciliation happen?
Reconciliation is scheduled with
queueMicrotask,
which means that it will happen very soon, but asynchronously. This is similar
to something like setTimeout(reconcile, 0) with a bit more immediacy orPromise.resolve().then(reconcile) with less overhead. This is done so
that all of the updates that occur as a result of a single logical event will
be batched into one reconciliation.
You can force a reconciliation to take place synchronously with the discrete
option to editor.update (demonstrated below).
Why do tests use `await editor.update(…)`
You may notice that many tests look like this:
await editor.update(updateA);
await editor.update(updateB);An astute observer would notice that this seems very strange, sinceeditor.update() returns void and not Promise<void>. However,
it does happen to work as you would want it to because
the implementation of Promise uses the same microtask queue.
It's not recommended to rely on this in browser code as it could depend on
implementation details of the compilers, bundlers, and VM. It's best to stick
to using the discrete or the onUpdate callback options to be sure that
the reconciliation has taken place.
Ignoring any other microtasks that were scheduled elsewhere,
it is roughly equivalent to this synchronous code:
editor.update(updateA, {discrete: true});
editor.update(updateB, {discrete: true});At a high level, very roughly, the order of operations looks like this:
editor.update()is calledupdateA()is called and updates the editor stateeditor.update()schedules a reconciliation microtask and returnsawaitschedules a resume microtask and yields control to the task executor- the reconciliation microtask runs, reconciling the editor state with the DOM
- the resume microtask runs
How do I listen for user text insertions?
Listening to text insertion events is problematic with content editables in general. It's a common source of bugs due to how
different browsers and third-party extensions interact with the DOM. Whilst it's possible to use DOM events like input andbeforeinput to gauge some of the possible cases where a user has inserted text, these are hardly reliable and also don't
take into account edge-cases. Instead, Lexical prefers to consider any change as a possible user input, and as such doesn't
make a distinction between the cases. This is important for tools like spellcheck, browser extensions, IME, speech-to-text,
screen readers and other external tools that often don't reliably trigger a reliable event sequence (some don't even trigger
any events at all!).
For those wanting to react to a text change and possibly block/alter the intent, the recommended approach is to use a node
transform. This also plays nicely with other sub-systems at play that might also be looking to do the same thing as you.
For those who just want to know of the changes, this can be achieved using a text content listener or an editor update listener.
How do I clear the contents of the editor?
You can go this by calling clear() on the RootNode in an update callback:
editor.update(() => {
$getRoot().clear();
})How do I listen to specific key down events?
You can leverage Lexical's command listening system. Lexical provides specific commands for many common keyboard operations, such as:
KEY_ARROW_LEFT_COMMANDKEY_ARROW_RIGHT_COMMANDKEY_ARROW_UP_COMMANDKEY_ARROW_DOWN_COMMANDKEY_SPACE_COMMANDKEY_ENTER_COMMANDKEY_BACKSPACE_COMMANDKEY_DELETE_COMMANDKEY_TAB_COMMANDKEY_ESCAPE_COMMAND
import {KEY_ENTER_COMMAND, COMMAND_PRIORITY_LOW} from 'lexical';
editor.registerCommand(KEY_ENTER_COMMAND, (event: KeyboardEvent) => {
// Handle enter key presses here
return false;
}, COMMAND_PRIORITY_LOW);You can use the generic KEY_DOWN_COMMAND command to listen
to all keydown events. Do note, that returning true in your listener will prevent any
other key based commands from firing, so in most cases you'll want to return false from
the command listener.
import {KEY_DOWN_COMMAND, COMMAND_PRIORITY_LOW} from 'lexical';
editor.registerCommand(KEY_DOWN_COMMAND, (event: KeyboardEvent) => {
// Handle event here
return false;
}, COMMAND_PRIORITY_LOW):::tip
If you do return true from any listener with an event payload, you likely
also should call event.preventDefault() unless your command relies on the
browser's native processing of that event.
:::
Why do I get "does not implement .getType()" or "does not match registered node"?
LexicalNode: Node FooNode does not implement .getType().
Create node: Type heading in node HeadingNode does not match registered node HeadingNode with the same typeThese almost always mean your app resolved two copies of lexical, not that
the node is misdeclared — the instanceof and registration checks compare class
references, and the two copies have different ones. Run npm ls lexical (orpnpm why lexical) from the app root: it must report exactly one version.
See One Lexical per app for the causes and
the fix, including why a library must declare lexical in peerDependencies.
Which module formats are published? (ESM, CommonJS, Node.js, React Native)
Every Lexical package is published as ES modules only. The packages declare"type": "module", and their exports maps offer a development and aproduction build plus a default entry that picks one of the two at runtime
from process.env.NODE_ENV.
Bundlers (webpack, Vite, Rollup, esbuild with the conditions set) resolve
thedevelopmentorproductioncondition and get exactly one build.Node.js resolves
default, which loads both builds and exports one. To
load a single build, pass the condition on the command line:node --conditions=production app.js(or--conditions=development).CommonJS code can
require()the packages on Node.js 20.19 or later,
which loads ES modules fromrequire()as long as nothing in the module
graph uses top-levelawait(Lexical's builds do not). Older Node.js
versions have to useawait import('lexical')instead. Loading is all
thatrequire()guarantees: Lexical's builds import their own
dependencies as ES modules, so a dependency that a CommonJS application
also loads throughrequire()can end up in the application twice, once
per module system, with separate classes and module state. That breaks
any integration that hands such a dependency's objects to Lexical, for
exampleyjsdocuments given to@lexical/yjs, or@preact/signals-core
effects observing signals from@lexical/extension. Load those
dependencies as ES modules too (await import('yjs')), or move the
application to ES modules.React Native (Metro) bundles the packages without extra configuration,
but Metro does not set thedevelopment/productionconditions and does
not drop the build thedefaultentry leaves unused, so both end up in the
bundle. To bundle only the one that matches the build mode, add the
condition for Lexical's packages inmetro.config.js:jsconst {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); module.exports = mergeConfig(getDefaultConfig(__dirname), { resolver: { resolveRequest: (context, moduleName, platform) => context.resolveRequest( /^(lexical|@lexical\/)/.test(moduleName) ? { ...context, unstable_conditionNames: [ ...context.unstable_conditionNames, context.dev ? 'development' : 'production', ], } : context, moduleName, platform, ), }, });
Packages Lexical Website Docs Maintainers Guide Link (`packages/lexical-website/docs/maintainers-guide-link.md`)
sidebar_position: 99
Developing against a local Lexical checkout
If you're iterating on Lexical itself and want a downstream app to pick
up your changes without publishing, the Lexical packages support two
linking workflows.
Both rely on the fact that each packages/<name>/ directory is its own
publishable npm package: after a build, packages/lexical/package.json
plus the dist/ it sits next to is a complete tarball. There is no
intermediate npm/ directory and no copy step — pnpm pack /pnpm publish run straight from the package root.
Option A: built artifacts (works for any consumer)
Build the artifacts, then point your downstream project at the package
directory:
# in the Lexical checkout
pnpm install
pnpm run build-release # produces dist/<Name>.{dev,prod}.js + .d.ts + .js.flow
# in your downstream app
pnpm add link:/path/to/lexical/packages/lexical
pnpm add link:/path/to/lexical/packages/lexical-react # etc.pnpm follows the link: protocol by creating a real symlink into yournode_modules/, so subsequent rebuilds of Lexical (e.g.pnpm run build in the Lexical checkout) become visible immediately.
Standard exports resolution picks up the development /production conditions, so dev builds load the unminified variants and
prod builds load the minified ones automatically. Only ESM is published;
a CommonJS consumer loads the same files through require(esm)
(Node.js 20.19+).
If you prefer the dev-only loop (faster builds, no minification), usepnpm run build instead of build-release. The fork modules emitted
by a dev-only build re-export the .dev variant unconditionally; you
won't be able to pnpm publish from such a checkout because the
publish guard will report missing .prod files.
file:/path/to/... works too if your package manager treats it as a
symlink (modern pnpm does); npm copies the directory intonode_modules instead, which loses any live-reload benefit but is
otherwise equivalent for one-shot installs.
Option B: TypeScript source (no Lexical build needed)
For the tightest dev loop — edit a Lexical source file and have your
downstream bundler pick it up on the next request — opt into thesource export condition. Each public package's exports map exposes
its ./src/<entry>.tsx? next to the compiled development/production/default conditions, so a bundler configured withresolve.conditions: ['source', …] will load TypeScript directly out
of the linked package.
The only requirement is bundler support: your consumer's bundler has to
be configured to pick up the source condition AND to transform.ts/.tsx from inside node_modules. Vite, Rspack, and Parcel can all be
told to do this; webpack needs a loader override for the linked package.
There is no aliasing and no extra define to set up. Internal utilities
the source depends on are a real (internal) dependency, @lexical/internal,
which resolves through normal package resolution; the React/test helpers are
package-internal; and the dev/prod branch is process.env.NODE_ENV !== 'production', which every mainstream bundler substitutes by default. Sosource mode works without any resolve.alias entries or build-time globals
— including for the transitive @lexical/* packages.
One thing the compiled bundles have that the sources do not is the/* @__PURE__ */ annotations on module-scope calls to defineExtension,createCommand, createState, and friends. Lexical's own build injects
them, and without them a bundler has to assume those calls have side effects
and will keep every extension, command, and rule definition your app never
uses. Add @lexical/compiler
to your build to get the same annotations out of source mode — it annotates
the factory calls in your own code too.
Minimal Vite setup
// vite.config.ts
import {pureAnnotations} from '@lexical/compiler/PureAnnotations';
import {defineConfig} from 'vite';
export default defineConfig({
plugins: [pureAnnotations()],
resolve: {
conditions: ['source', 'development', 'module', 'browser', 'default'],
},
});Because a link:ed checkout is the same version as the plugin, you can also
pass pureAnnotations({inline: true}), which replaces calls to the trivial
factories (safeCast, defineExtension, configExtension, ...) with the
literal they would have returned rather than annotating them.
@lexical/compiler/PureAnnotations also exports the transform itself
(transformPureAnnotations) for bundlers without a Rollup-style plugin API;
see its README for the options.
The integration fixture atscripts/__tests__/integration/fixtures/lexical-link-source-mode/
exercises exactly this setup and is the canonical reference if you need
a fuller example.
[!NOTE]
@lexical/internalis published only so this resolution works (and for
direct internal consumers); the compileddist/artifacts inline it, so
a normalnpm installoflexicalnever executes a separate copy.
Packages Lexical Website Docs Maintainers Guide (`packages/lexical-website/docs/maintainers-guide.md`)
Maintainers' Guide
This is the grimoire of arcane knowledge covering the overall organization
of the Lexical monorepo, including its conventions, quirks, and
configurations.
Monorepo Organization
Workspaces
The monorepo is configured with
pnpm workspaces; the set of workspace
packages is declared in the top-level pnpm-workspace.yaml. This mostly means that all packages share a
top-level pnpm-lock.yaml and pnpm -C {package} run {command} is often
used to run a command from a nested package's package.json.
Private
Some packages in the monorepo do not get published to npm, for example:
packages/lexical-devtools- browser extension for working with Lexical
sitespackages/lexical-playground- the
playground.lexical.dev demo sitepackages/lexical-website- the lexical.dev
docusaurus website that you may even be reading right now
Internal runtime code shared by more than one package lives inpackages/lexical-internal(@lexical/internal). Unlike the others above
it is published, but only so its source resolves through normal package
resolution (thesourceexport condition used by linked-checkout
consumers); the compiled packages inline it, so it is never executed as a
separate runtime dependency. It is not a public API and has no semver
guarantees — see Developing against a local Lexical
checkout.
It is required that private packages, and any other package that should not
be published to npm, have a "private": true property in their package.json.
If you have an in-progress package that will eventually be public, but is
not ready for consumption, it should probably still be set to"private": true otherwise the tooling will find it and publish it.
Package naming conventions
Overall
| Usage | Convention |
|---|---|
| Directory name | packages/lexical-package-name |
| Entrypoint | packages/lexical-package-name/src/index.{ts,tsx} |
| Flow types | packages/lexical-package/flow/LexicalPackageName.js.flow |
| package.json name | @lexical/package-name |
| Documentation | packages/lexical-package-name/README.md |
| Unit Tests | packages/lexical-package-name/src/__tests__/unit/LexicalPackageName.test.{ts,tsx} |
| dist (gitignore'd build product) | packages/lexical-package-name/dist |
| npm (gitignore'd prerelease product) | packages/lexical-package-name/npm |
| www entrypoint | packages/lexical-package-name/LexicalPackageName.js |
Multiple module export (@lexical/react)
Instead of having a single module, some packages may have many modules
(currently only @lexical/react) that are each exported separately.
In that scenario, there should be no index.ts entrypoint file and every module
at the top-level should be an entrypoint. All entrypoints should be a
TypeScript file, not a subdirectory containing an index.ts file.
The update-packages script will ensure that the
exports match the files on disk.
Creating a new package
The first step in creating a new package is to create the workspace directory
and package.json file. The example we will use is the steps that were used
to create the lexical-eslint-plugin, which will be published to npm as@lexical/eslint-plugin.
Create the workspace
mkdir -p packages/lexical-eslint-pluginCreate the initial package.json file (you can base it on an existing package
or use the template below):
packages/lexical-eslint-plugin/package.json
packages/lexical-eslint-plugin/package.json{
"name": "@lexical/eslint-plugin",
"description": "",
"keywords": [
"lexical",
"editor"
],
"version": "0.14.3",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/facebook/lexical.git",
"directory": "packages/lexical-eslint-plugin"
},
"main": "LexicalEslintPlugin.js",
"types": "index.d.ts",
"bugs": {
"url": "https://github.com/facebook/lexical/issues"
},
"homepage": "https://github.com/facebook/lexical#readme"
}Some next steps for this package.json before moving on:
- Update the description
- Add appropriate keywords
Create the initial source file
mkdir -p packages/lexical-eslint-plugin/src
code packages/lexical-eslint-plugin/src/index.tsHere are some minimal examples of those files that you might start out with.
I've elided the license header, the eslint header/header fixer will help you
with that!
packages/lexical-eslint-plugin/src/index.ts
packages/lexical-eslint-plugin/src/index.tsimport {name, version} from '../package.json';
const plugin = {
meta: {name, version},
rules: {},
};
export default plugin;Run update-packages to generate boilerplate docs & config
pnpm run update-packagesThis will set up the tsconfig, flow, etc. configuration to recognize your
new module. It will also create an initial README.md using only the
description from the package.json.
Create an initial unit test
mkdir -p packages/lexical-eslint-plugin/src/__tests__/unit
code packages/lexical-eslint-plugin/src/__tests__/unit/LexicalEslintPlugin.test.tspackages/lexical-eslint-plugin/src/__tests__/unit/LexicalEslintPlugin.test.ts
packages/lexical-eslint-plugin/src/__tests__/unit/LexicalEslintPlugin.test.tsimport plugin from '@lexical/eslint-plugin';
describe('LexicalEslintPlugin', () => {
it('exports a plugin with meta and rules', () => {
expect(Object.keys(plugin).sort()).toMatchObject(['meta', 'rules']);
});
});Scripts for development
pnpm run update-packages
This script runs: update-version, update-tsconfig, update-flowconfig,
create-docs, and create-www-stubs. This is safe to do at any time and will
ensure that package.json files are all at the correct versions, paths are
set up correctly for module resolution of all public exports, and that
various defaults are filled in.
These scripts can be run individually, but unless you're working on one
of these scripts you might as well run them all.
pnpm run prepare-release
This runs build-release to produce all of the artifacts each public
package needs (the dev/prod ESM variants plus their fork modules,.d.ts declarations, and .js.flow stubs underpackages/<name>/dist/), then runs the publish-time guard inscripts/npm/prepare-release.mjs to confirm every path the package'sexports/main/module/types fields reference actually exists on
disk. The guard fails the build if e.g. you ran pnpm run build (dev
only) and then tried to publish — the .prod.js files would be
missing.
Only ESM is published to npm. Every public package is a"type": "module" package (update-version adds the field), so its
build is plain .js. A CommonJS consumer gets the same files through
Node's require(esm) (Node.js 20.19+), which is why the fork module that
the exports map's default condition resolves to has no top-level await.
The CommonJS variants are only built for www (pnpm run build-www).
Because a .d.ts in a "type": "module" package is an ES module
declaration, the build gives the relative imports tsc emits an explicit.js extension (from './LexicalEditor.js'): TypeScript's nodenext
resolution does not resolve extensionless relative imports in ESM, and a
consumer with skipLibCheck would silently get any for everything.scripts/__tests__/integration/declaration-resolution.test.mjs checks
that a nodenext consumer of every entry point still sees the types.
Each package is its own publish root: packages/<name>/ IS the
publishable npm package after build-release. pnpm publish is run
directly from that directory by scripts/npm/release.mjs so pnpm's
automatic workspace:* rewriting and the files whitelist do the
right thing without an intermediate npm/ copy step.
This will also update scripts/error-codes/codes.json, the mapping of
production error codes to error messages. It's imperative to commit the
result of this before tagging a release.
pnpm run ci-check
Check flow, TypeScript, prettier and eslint for issues. A good command to run
after committing (which will auto-fix most prettier issues) and before pushing
a PR.
pnpm run flow
Check the Flow types
pnpm run tsc
Check the TypeScript types
pnpm run tsc-extension
Check the TypeScript types of the lexical-devtools extension
pnpm run test-unit
Run the unit tests
pnpm run lint
Run eslint
pnpm run generate-node-json
Regenerate the specialized JSON serialization code for the built-in node
classes. A node's serialization schema
states everything about a serialized property ahead of time — which accessor
or field it uses, what its default is, what its domain admits — so the
generic walk over that schema can be compiled into straight-line code. This
script does that compiling; the output is checked in.
It writes one module per package, beside the nodes it serializes:
| Module | Classes |
|---|---|
packages/lexical/src/LexicalGeneratedJSON.ts |
ElementNode, TextNode, ParagraphNode, LineBreakNode, TabNode |
packages/lexical-rich-text/src/LexicalRichTextGeneratedJSON.ts |
HeadingNode, QuoteNode |
packages/lexical-link/src/LexicalLinkGeneratedJSON.ts |
LinkNode, AutoLinkNode |
packages/lexical-mark/src/LexicalMarkGeneratedJSON.ts |
MarkNode |
packages/lexical-list/src/LexicalListGeneratedJSON.ts |
ListNode, ListItemNode |
packages/lexical-table/src/LexicalTableGeneratedJSON.ts |
TableNode, TableRowNode, TableCellNode |
packages/lexical-code-core/src/LexicalCodeCoreGeneratedJSON.ts |
CodeNode, CodeHighlightNode |
packages/lexical-react/src/shared/LexicalReactGeneratedJSON.ts |
DecoratorBlockNode |
Each class receives its own generated code through its $config'sgenerated property, so nothing has to match code to class by type string at
runtime. What the property holds is a factory: registration calls it with the
class's composed schema, and the generated code reads the lookup tables it
needs (getterTable, setterTable, aliasedValue's) off that schema throughgetterTableOf, setterTableOf and aliasTableOf, so a generated module
carries no copy of a table and nothing about a table's contents is written at
build time — only its type, so the field a value is assigned to is still
checked. A subclass inherits the code along with the schema when its compiled
accessor tables are the ones the code was generated from — checked entry for
entry at registration — and runs it over its own schema, while one that
overrides an accessor a field stands in for, or declares a serialized property
of its own, resolves differently and takes the schema-driven walk instead.
Generated exporters read type off the node for the same reason.
Three things are worth knowing before touching it:
- The output is verified, not trusted. The import direction is the
untrusted-JSON boundary, so every generated parser is run against the schema
it was compiled from over a corpus drawn from that schema plus a fixed set of
hostile values ('__proto__','toString','1e999', …). A property whose
schema cannot be compiled faithfully takes its class out of the import half
rather than shipping a parser that disagrees with the walk — the script says
so on stdout when it happens. - A stale checkout fails the tests.
LexicalGeneratedJSON.test.ts
regenerates into a temporary directory and compares every file byte for
byte, and separately asserts that each generated exporter agrees with the
schema-driven walk for both the legacy and compact forms. Change a schema
without rerunning this script and that test fails. - Two lists, deliberately. The script runs in two phases, because reading
the schemas means importing the packages and each package imports the file
the script writes for it. Phase one replaces every output with a valid
do-nothing stub from the staticMANIFESTinscripts/shared/generateNodeJSONManifest.mjsso the imports always succeed;
phase two re-enters undertsxand writes the real thing fromPACKAGESinscripts/shared/generateNodeJSON.mjs, which is also what the drift test
runs in-process. Adding a class means editing both lists, and the generation
fails loudly if they disagree.
The compact form compares each property against its default. A primitive
default is a literal; a reference-typed default has no literal a value could
be ===, so it gets the structural test the schema's own equality reduces to
where that can be stated — MarkNode's ids, whose default is an empty array,
becomes a length test — and every emitted comparison is verified against that
equality over a corpus, the way a parse is. A default the generator cannot
state that way (an object, a non-empty array, a non-finite number) takes the
class out of the compact half only: the script says so on stdout and the other
forms are generated as usual. A class that carries flat NodeState is generated
like any other; the walk applies the state before handing the node to the
generated parser, the mirror of how export appends it after the generated
literal.
afterCloneFrom is generated too, and is the one direction whose fallback is
not the walk. A schema field is where a property is stored, so every class
that declares one gets an afterCloneFrom synthesized at registration —
generated straight-line code when the class has some, and otherwise a loop over
the field names — copying the fields that class's own $config declared and
delegating the rest through super, which is why the emitted function covers
one class's own fields and nothing above it. A field an ancestor declares too
is left to the ancestor, whose method has already assigned it: re-declaring an
inherited property changes how it is serialized, not where it is stored, so a
class that only re-declares gets no method at all — TabNode, which restatesTextNode's text, detail and mode, is the in-tree case and simply
inherits TextNode's. Both accessor directions are read
for a field name, and the declared field is used rather than the oneresolveGetterAccessor resolves to: an override changes how a property is
serialized, not where it lives. A class that writes its own afterCloneFrom is
left alone and owns all of its properties, which is how ElementNode keeps
carrying __first/__last/__size and its slot bookkeeping; so is a property
declared through accessor methods on both sides, which names no field for
anything to copy. No in-tree node is in that second position — a property held
in a field says so with setter: {field, method} and stays derived, asMarkNode's ids does — so the boilerplate that remains is ElementNode's
and CodeNode's, and each of those calls the generated afterClone<Class>
for its schema half and writes only the fields no schema describes.ownSchemaFields in LexicalUtils.ts is the single definition of that field
list, called by both the generator and the synthesized fallback.
The schema-to-JavaScript compiler itself lives in @lexical/compiler'sSchemaJsonCodegen entry point, so it is testable independently of the
generator that drives it.
Scripts for release managers
pnpm run extract-codes
This will run a build that also extracts the generated error codes.json file.
This should be done, at minimum, before each release, but not in any PR as
it would cause conflicts between serial numbers.
It's safe and probably advisable to do this more often, possibly any time a
branch is merged to main.
The codes.json file is also updated any time a release build is generated
as a failsafe to ensure that these codes are up to date in a release.
This command runs a development build to extract the codes which is much
faster as it is not doing any optimization/minification steps.
pnpm run increment-version
Increment the monorepo version. The -i argument must be one ofminor | patch | prerelease.
The postversion script will:
- Create a local
${CHANNEL}__releasebranch (the version-named${npm_package_version}__releasebranch is only a remote push target,
and only when the channel is notnightly) pnpm run update-versionto update example and sub-package monorepo dependenciespnpm installto update the pnpm-lock.yamlpnpm run update-packagesto update other generated configpnpm run extract-codesto extract the error codes andpnpm run update-changelogto update the changelog (both only whenCHANNELislatest)- Create a version commit and tag from the branch
This is typically executed through the version.yml GitHub Workflow which
will also push the tag and branch.
pnpm run changelog
Update the changelog from git history.
pnpm run release
Prerequisites: all of the previous release manager scripts,
plus creating a tag in git, and likely other steps.
Runs prepare-release to do a full build and then uploads to npm.
pnpm run setup-trusted-publishing
One-time (idempotent) helper to register every public package with
npm trusted publishing.
Re-run it whenever a new public package is added.
Prerequisites
- Node.js — whatever the repo's root
package.json#engines.nodesays (currently>=20.19.0). Running with Node 24+ is recommended because that's what CI uses for publishes. - pnpm — pinned by
package.json#packageManager(currently[email protected]). Activate with corepack or install directly. - npm CLI —
npm ≥ 11.10(npm i npm@latest). Thenpm trustsubcommand was added in npm 11; older versions will fail the preflight check. - An authenticated npm session (
npm login --registry https://registry.npmjs.org) on a publisher account that has account-level 2FA enabled and write access to every@lexical/*package.
Usage
Run in check-only mode first:
pnpm run setup-trusted-publishingFor each public package in the monorepo, it querieshttps://registry.npmjs.org and reports whether the name is already
claimed. Packages that don't exist on the registry are listed; you
can re-run with --bootstrap to publish a deprecated0.0.0-bootstrap.0 placeholder under the bootstrap dist-tag so the
name can be claimed:
npm login --registry https://registry.npmjs.org
pnpm run setup-trusted-publishing --bootstrapOnce a package exists on the registry, you can configure trusted
publishing for it programmatically by adding --setup-trust. This
runs npm trust github under the hood (requires npm ≥ 11.10 and an
authenticated session with account-level 2FA on the publishing
account), and is idempotent — the script reads the existing trust
configuration for each package via a read-only registry call (no OTP)
and skips packages whose config already matches:
npm login --registry https://registry.npmjs.org
pnpm run setup-trusted-publishing --setup-trustnpm trust github is a write operation, so each package that does
need configuring will trigger a one-time-password / web-auth prompt.
On the first prompt npm prints a URL; open it in a browser, sign in,
and tick "Skip two-factor authentication for the next 5 minutes".
Subsequent packages in the same run will then go through without
re-prompting. The script also inserts a small (~2 s) pause between
calls to stay under the registry's E429 rate limit.
For full first-time setup of a brand-new monorepo, combine both flags:
pnpm run setup-trusted-publishing --bootstrap --setup-trustWhen adding a single new package to an existing monorepo — the common
case going forward — pass its name so the run only touches that package
instead of re-checking all 30+ already-configured ones (which just prints a
wall of CONFLICT lines). The name can be the full npm name or the unscoped
short name, and --package / positional args are interchangeable and
repeatable:
pnpm run setup-trusted-publishing --bootstrap --setup-trust @lexical/a11y
# equivalently: --package a11yUseful flags:
--package <name>(or a positional<name>, repeatable) — restrict the run to the given package(s), matched by full npm name (@lexical/a11y) or unscoped short name (a11y). Omit to process every public package.--dry-run— print what would happen without touching the registry (works with both--bootstrapand--setup-trust)--workflow <filename>— override the workflow filename (defaultpre-release.yml)--repo <owner/name>— override the GitHub repo (defaultfacebook/lexical)--stub-version <semver>— override the placeholder version (default0.0.0-bootstrap.0)--registry <url>— override the npm registry
In the default (check-only) mode the script also prints the npmjs.com/access URL for each existing package and the exact values to enter
manually, as a fallback for when npm trust github isn't an option.
Testing trusted publishing from a PR branch
The "Publish to NPM" workflow (pre-release.yml) exposes ref,channel, and increment-version inputs so it doubles as a test
harness. Picking a branch in the "Run workflow" dropdown selects
which version of the workflow files run, and the inputs determine
what actually gets published. The workflow has no NPM_TOKEN secret to
fall back on — publishes always go through OIDC trusted publishing —
so a misconfigured trust setup fails loudly rather than silently
falling through to token auth.
A safe end-to-end test looks like:
| Input | Value |
|---|---|
| Branch (dropdown) | your PR branch |
ref |
your PR branch (same value) |
channel |
dev |
increment-version |
checked |
ignore-previously-published |
unchecked |
With increment-version on, the run bumps package.json to a fresh
prerelease (e.g. 0.46.0-dev.0), commits + tags it on a dev__release
branch on origin, and publishes the monorepo under the dev dist-tag
via OIDC. The latest tag is untouched, so default npm install
users are unaffected. After it succeeds:
npm view lexical@dev version # → the just-published prerelease
npm view lexical@latest version # → unchangedCleanup (the prerelease itself can't be reused, but the git refs
should go):
git push --delete origin v0.46.0-dev.0 dev__release 0.46.0-dev.0__releaseThe increment-version=true + channel=latest combination is refused
by the workflow's guard job — real latest releases must go throughversion.yml first.
Continuous releases (pkg.pr.new)
Every commit to a pull request and every commit to main publishes all public
packages to pkg.pr.new through
the pkg-pr-new.yml workflow. Nothing goes to npm: the nightly, next and latest
channels above are unchanged and still publish through call-release.yml.
npm i https://pkg.pr.new/lexical@9372 # latest commit of PR #9372
npm i https://pkg.pr.new/lexical@4282a68 # a specific commit
npm i https://pkg.pr.new/lexical@main # tip of mainThe bot comments install commands on each pull request. Previews are built
with pnpm run prepare-release, the same build as the npm channels, afterscripts/npm/preview-version.mjs stamps a version in the nightly line:0.52.1-pr.9372.g4282a68 for a PR and 0.52.1-main.g4282a68 for main, next
to 0.52.1-nightly.20261019.0 on npm. That version is also whatLexicalEditor.version reports. Dependencies between Lexical packages are
rewritten to the same commit's pkg.pr.new URLs, so @lexical/react from a
preview depends on lexical from that preview, not on an npm release.
The bot's comment lists npm commands, and npm dedupes those dependencies by
name and version, so @9372 or @main URLs are fine. pnpm and yarn key URL
dependencies by URL instead: install with the long commit URLs that the
packages use for each other, or you get more than one copy of lexical. pnpm
11+ also refuses URL dependencies of URL dependencies unlessblockExoticSubdeps is off. Each run's job summary lists these commands:
pnpm add --config.block-exotic-subdeps=false \
lexical@https://pkg.pr.new/facebook/lexical/lexical@4282a68 \
@lexical/react@https://pkg.pr.new/facebook/lexical/@lexical/react@4282a68
yarn add \
lexical@https://pkg.pr.new/facebook/lexical/lexical@4282a68 \
@lexical/react@https://pkg.pr.new/facebook/lexical/@lexical/react@4282a68On a cold cache, yarn 1 can fail with "the file appears to be corrupt" while
it downloads the same URL for several packages at once. Run it again.
The workflow requires the pkg.pr.new GitHub App
to be installed on the repository.
Release Procedure
This is the current release procedure for public releases, at least as of
May 2024 (~0.15.0).
The main branch should be "frozen" during this procedure (no other PRs should
be merged during this time). This avoids a mismatch between the contents of
the GitHub release (created from main in step 1) and the NPM release (created
from main in step 4).
- Create a new version with the Github Actions "Create New Release Branch" workflow (
version.yml) - Raise a PR against version branch created by that action
- After PR is approved with passing tests, merge PR
- After PR is merged to main, publish to NPM with the Github Actions "Publish to NPM" workflow (
pre-release.yml) - Create a GitHub release from the tag created in step 1, manually editing the release notes
- Announce the release in #announcements on Discord
Release Protocol
- All PRs with breaking changes must have
[Breaking Change]in the PR's title with documentation of what followup actions consumers of the lexical library need to be aware of. - Monthly releases happen on the last week of the month, with a minor increment (eg. v0.20+1.0).
- Anything in between will be a patch increment (eg. 0.20.0+1), unless there is a breaking change.
Website Team Page
The team page displays core team
members, emeriti, and distinguished contributors. The team.json data is
generated from GitHub contributor information and some predetermined decisions in
the script to acknowledge emeriti and historically important distinguished contributors.
To update the team page data:
pnpm run update-team-dataThis fetches the latest contributor data from GitHub and categorizes team members
based on recent activity (last 12 months). See packages/lexical-website/src/data/README.md
for more details on configuration and team categorization logic.
Packages Lexical Website Docs Testing (`packages/lexical-website/docs/testing.md`)
sidebar_position: 6
Testing
Lexical has three tiers of tests: unit tests in jsdom, browser tests in a real browser, and end-to-end tests against the playground. Because contentEditable doesn't work in jsdom, anything involving real user input needs a browser.
Unit tests (jsdom)
Unit tests run in vitest with a jsdom environment. They are fast (the full suite finishes in ~20 seconds) and cover the majority of Lexical's API surface.
pnpm test-unitTest files live under packages/**/src/__tests__/unit/ and packages/**/__tests__/unit/.
Why user input doesn't work in jsdom
Calls like userEvent.type() or fireEvent.input() from React Testing Library will not produce any effect:
:::info
jsdom does not implement contentEditable editing. In a real browser, a keystroke inside a contentEditable element goes through the browser's native editing engine, which modifies the DOM and fires beforeinput/input events that Lexical observes. jsdom has no such engine, so dispatching synthetic InputEvents has no effect.
The same applies to ProseMirror, Slate, Tiptap, and any other contentEditable-based editor. The approach is the same everywhere: use the editor's API to manipulate state in unit tests, and use a real browser when you need real input.
:::
What you can test in jsdom
Anything that goes through Lexical's API rather than the browser's input pipeline:
import {buildEditorFromExtensions} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
CONTROLLED_TEXT_INSERTION_COMMAND,
} from 'lexical';
const editor = buildEditorFromExtensions({
$initialEditorState: () => {
$getRoot().append(
$createParagraphNode().append($createTextNode('hello')),
);
},
dependencies: [RichTextExtension],
name: 'test',
});
const root = document.createElement('div');
root.contentEditable = 'true';
document.body.appendChild(root);
editor.setRootElement(root);Insert text via the selection API:
editor.update(() => {
$getRoot().selectEnd();
}, {discrete: true});
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(' world');
}
}, {discrete: true});
editor.read(() => {
expect($getRoot().getTextContent()).toBe('hello world');
});{discrete: true} makes the update commit synchronously instead of being batched, so each step's effects are visible to the next one.
Insert text via a command. This also requires a selection to be set first:
editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, ' world');Both approaches update the editor state and the DOM. Use whichever is closer to the code path you're testing.
What you cannot test in jsdom
- Typing by dispatching
KeyboardEventorInputEventon the DOM to insert text - IME / composition sequences
- Selection changes driven by mouse clicks or arrow keys
- Clipboard paste with real browser
DataTransfer
KeyboardEvent dispatch does work for non-text-input events. KEY_ENTER_COMMAND, KEY_TAB_COMMAND, KEY_ARROW_* commands, and shortcuts can all be tested in jsdom, either by dispatching the event on the DOM or via editor.dispatchCommand.
If your test needs real text input or composition, use browser tests.
Editor cleanup with `using`
buildEditorFromExtensions returns an editor with a dispose() method and Symbol.dispose support. Declaring it with using automatically cleans up registrations at the end of the block:
it('inserts text', () => {
using editor = buildEditorFromExtensions({
dependencies: [RichTextExtension],
name: 'test',
});
// editor.dispose() is called automatically at the end of the block
});This only works in unit tests (jsdom). The using syntax is not supported in all browser engines, so browser tests should call editor.dispose() manually or use vitest's onTestFinished callback instead.
Property-based tests with `@lexical/fast-check`
A node that declares a serialization schema has already stated what each of its properties accepts, so tests do not have to invent examples. @lexical/fast-check turns that declaration into a fast-check arbitrary:
npm install --save-dev @lexical/fast-check fast-checkimport {nodeArbitrary} from '@lexical/fast-check';
import * as fc from 'fast-check';
fc.assert(
fc.property(nodeArbitrary(MyNode), props => {
// props is a serialized MyNode, drawn from its own schema's domain
}),
);This is worth reaching for whenever a hand-written fixture would tend to use default values, because a bug that drops a property compares equal to the default — the test looks like it passed exactly when the bug is invisible. The clearest case is afterCloneFrom: declaring a new property and forgetting to copy it there is silent, since the field still exists on the clone and simply holds the constructor's default. Generated values catch it, and fast-check shrinks the failure to the property at fault:
Counterexample: [{"style":" "}]
- "style": " ",
+ "style": "",The package's README has the full example, and packages/lexical-fast-check/src/__tests__/unit/ runs it against TextNode.
Browser tests (vitest browser mode)
Browser tests run in a real browser via vitest's browser mode, backed by Playwright. The browser has a real contentEditable engine, so input events, composition, and selection work as expected.
pnpm test-browser # default: chromium
VITEST_BROWSER=firefox pnpm test-browser # firefox
VITEST_BROWSER=chromium,firefox pnpm test-browser # multiple browsersTest files live under packages/**/src/__tests__/browser/. They look like normal vitest tests. Here's an example that uses the compose() and korean() helpers to simulate an IME sequence:
import {buildEditorFromExtensions} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {$getRoot, $createParagraphNode} from 'lexical';
import {expect, test, onTestFinished} from 'vitest';
import {compose, korean} from './utils/compose';
test('IME composition inserts Korean text', async () => {
const editor = buildEditorFromExtensions({
$initialEditorState: () => {
$getRoot().append($createParagraphNode());
},
dependencies: [RichTextExtension],
name: 'test',
});
const rootElement = document.createElement('div');
rootElement.contentEditable = 'true';
document.body.appendChild(rootElement);
editor.setRootElement(rootElement);
onTestFinished(() => {
editor.setRootElement(null);
rootElement.remove();
editor.dispose();
});
rootElement.focus();
await compose({editor, rootElement}, korean(['ㅎ', '하', '한']));
editor.read(() => {
expect($getRoot().getTextContent()).toBe('한');
});
});For a complete working setup with vitest browser mode configured from scratch, see the website-toolbar example.
End-to-end (E2E) tests
E2E tests use Playwright to drive the full playground application in Chromium, Firefox, and WebKit. They click, type, and verify what appears on screen, with no access to Lexical internals. Install the browsers first:
pnpm exec playwright installThen start the playground and run the tests:
pnpm start &
pnpm test-e2e-chromium # or -firefox, -webkitTests live under packages/lexical-playground/__tests__/ in e2e/ (feature coverage) and regression/ (one test per reported bug) directories.
When to use which tier
| What you're testing | Tier |
|---|---|
| Node creation, transforms, serialization | Unit (jsdom) |
| A node property's whole domain, not one example | Unit (jsdom), property-based |
| Command dispatch and handler logic | Unit (jsdom) |
| Extension registration and config | Unit (jsdom) |
| Real keystroke input and typing | Browser |
| IME composition (Korean, Japanese, Chinese) | Browser |
| Selection from user interaction | Browser |
Clipboard paste with real DataTransfer |
Browser |
| Full-application user scenarios | E2E |
| Cross-browser rendering differences | E2E |
Running specific tests
Pass a filename filter to run a subset of tests:
pnpm test-unit LexicalReconciler
pnpm test-browser composevitest matches the filter against file paths, so any substring of the filename works.
General guidelines
- New features must include tests.
- If it can break, it should have a test.
- Do not merge pull requests with failing tests — this blocks other people and releases.
Packages Lexical Website Docs Collaboration Faq (`packages/lexical-website/docs/collaboration/faq.md`)
sidebar_position: 2
Collaboration FAQ
Source of truth: Lexical State, Yjs and App's DB
It's recommended to treat the Yjs model as the source of truth. You can store the document to a database for indexing.
But, if possible, you should never forget the Yjs model, as this is the only way clients without internet access can reliably join and sync with the server.
You can also treat the database as the source of truth. This is how it could be achieved:
- Clients receive a
sessionIdwhen they connect to the server - When a client connects without an existing
sessionId, get the content from the database and create asessionId - When all clients disconnect, forget the room content and
sessionIdon the server after some timeout (e.g. 1 hour) - When a client reconnects, use that content on the server. Furthermore, get the
sessionIdfrom the client - When two clients with different
sessionIdreconnect, one of the clients should forget the room content. In this case the client will lose content - although it is very unlikely if you set the forget timeout (see point 2) very high.
Or, there is an ever simpler approach:
- When a client connects to the server, the server populates the room content if empty
- When all clients disconnect, the server forgets the room content after some timeout (e.g. 1 hour)
- When a client was not able to reconnect for 40 minutes, the client must forget its local updates and start fresh (this should be enforced by the server)
When the database is the source of truth, and if you want to be able to forget the Yjs model, you will always run into cases where clients are not able to commit changes. That's not too bad in most projects. It somehow limits you, because you can't cache the document on the client using y-indexeddb. On the other hand, it is much easier to maintain, and do Yjs upgrades. Furthermore, most people would say that SQL is a bit more reliable than Yjs.
* Based on the advice of the Yjs author - Kevin Jahns
Initializing `EditorState` from Yjs Document
It's achievable by leveraging headless Lexical and no-op provider for Yjs:
createHeadlessCollaborativeEditor.ts
/* Detailed source-code truncated for AI context efficiency. */Packages Lexical Website Docs Collaboration React (`packages/lexical-website/docs/collaboration/react.md`)
sidebar_position: 1
React
Lexical provides LexicalCollaborationPlugin and useCollaborationContext hook within @lexical/react to accelerate creation of the collaborative React backed editors.
This is on top of the Yjs bindings provided by @lexical/yjs.
:::tip
Clone Lexical GitHub repo, run pnpm i && pnpm run start and open http://localhost:3000/split/?isCollab=true to launch playground in collaborative mode.
:::
Getting started
This guide is based on examples/react-rich example.
Install minimal set of the required dependencies:
$ npm i -S @lexical/react @lexical/yjs lexical react react-dom y-websocket yjs:::note
y-websocket is the only officially supported Yjs connection provider at this point. Although other providers may work just fine.
:::
Get WebSocket server running:
This allows different browser windows and different browsers to find each other and sync Lexical state. On top of this YPERSISTENCE allows you to save Yjs documents in between server restarts so clients can simply reconnect and keep editing.
$ HOST=localhost PORT=1234 YPERSISTENCE=./yjs-wss-db npx y-websocketGet basic collaborative Lexical setup:
/* Detailed source-code truncated for AI context efficiency. */Initial editor content:
In a production environment, you should bootstrap the editor's initial content on the server. If bootstrapping was left to the client and two clients connected at the same time, they could both try to initialize the content resulting in document corruption.
Using the withHeadlessCollaborationEditor function from the FAQ page, you can create a bootstrapped Y.Doc with the following:
import type {CreateEditorArgs} from 'lexical';
import {$getRoot, $createParagraphNode} from 'lexical';
import {Doc} from 'yjs';
import {withHeadlessCollaborationEditor} from './createHeadlessCollaborativeEditor';
function createBootstrappedYDoc(nodes: CreateEditorArgs['nodes']): Doc {
return withHeadlessCollaborationEditor(nodes, (editor, binding) => {
editor.update(() => {
$getRoot().append($createParagraphNode());
}, {discrete: true});
// binding.doc is the Doc the editor synced into
return binding.doc;
});
}If you're simply following the above example to play around in a local dev environment, then you can add the following props to CollaborationPlugin to initialize the editor state client-side:
// Dev-testing only, do not use in real-world cases.
initialEditorState={$initialEditorState}
shouldBootstrap={true}See it in action
Source code: examples/react-rich-collab
Sharing one Yjs document between editors
By default a binding uses the XmlText shared type named root on the YjsDoc, so each editor needs its own document. Two options let several editors
live in one document instead, which is what applications like collaborative
sticky notes or comment threads usually want:
rootNamepicks a different top-level shared type, one per editor:
<CollaborationPlugin
id={`note-${noteId}`}
providerFactory={providerFactory}
rootName={`note-${noteId}`}
shouldBootstrap={true}
/>getXmlTextresolves the root yourself, for roots that are not top-level
shared types (for example anXmlTextstored in aY.Map). It takes
precedence overrootName:
const getXmlText = useCallback(
(doc) => doc.getMap('notes').get(noteId),
[noteId],
);
<CollaborationPlugin
id={`note-${noteId}`}
providerFactory={providerFactory}
getXmlText={getXmlText}
shouldBootstrap={true}
/>Give each editor its own id, as above: the id keys the document map and the
remote-cursor highlight registry, both of which are shared across the page, so
two editors mounted under one id overwrite each other's entries.
Both options are also available on createYjsBinding for non-React usage.
:::warning
The root is chosen when the binding is created, and a mounted editor cannot be
repointed at another root: changing rootName or getXmlText afterwards has no
effect. To switch documents, remount the editor — put a key that changes
with the document on the component that owns the LexicalComposer. Remounting
only the plugin is not enough: the editor would keep the previous document's
content and write it into the newly selected root.
:::
getXmlText runs while the binding is constructed, before the provider has
synced, so it has to resolve the root from whatever the local Doc holds at
that moment — reaching into a Y.Array that the server has not sent yet throws.
Either mount the editor only once the note exists locally, or have the callback
create the missing shared type. Write it to be idempotent: it can run more than
once for one editor (React StrictMode double-invokes render, and a remount
builds a new binding), and a callback that unconditionally stores a newXmlText would throw away what the previous call stored.
The returned XmlText is checked to be an XmlText that is integrated into the
document; that it is not already driving another live binding is yours to
guarantee, since a second binding over one root takes over the collab nodes
cached on its shared types.
The experimental CollaborationPluginV2__EXPERIMENTAL (andcreateBindingV2__EXPERIMENTAL) take the same two options, except that its
root is an XmlElement, so the callback is named getXmlElement. Create that
element as new Y.XmlElement() without a nodeName, which is how the root is
recognized.
Building collaborative plugins
Lexical Playground features set of the collaboration enabled plugins that integrate with primary document via useCollaborationContext() hook. Notable mentions:
CommentPlugin- features use of the separate provider and Yjs room to sync comments.ImageComponent- features use of theLexicalExtensionEditorComposerfor its nested caption editor.PollOptionComponent- showcases poll implementation usingnamefrom the collaboration context.StickyComponent- features use of theLexicalExtensionEditorComposerfor its nested editor as well as sticky note position real-time sync.
:::note
While these "playground" plugins aren't production ready - they serve as a great example of collaborative Lexical capabilities as well as provide a good starting point.
:::
Custom node property syncing
@lexical/yjs syncs custom node properties by constructing a fresh node instance and inspecting its own enumerable properties. This means every property that should be synced across peers must be assigned in the constructor, even if the initial value is undefined.
:::warning
Declaring a class property with TypeScript's optional syntax (foo?: Type) does not guarantee the property exists as an own enumerable property on the instance. Under TypeScript's default compilation settings (target < ES2022 without useDefineForClassFields), such declarations produce no initialization code.
:::
What to do
Always initialize every node property in the constructor. Conditional initialization such as if (someValue !== undefined) this.__someValue = someValue is not enough, because the default construction path still needs to create the property.
For example:
// ✅ Correct — property is guaranteed to be an own property
class MyNode extends ElementNode {
__someValue: string | undefined;
constructor(someValue?: string, key?: NodeKey) {
super(key);
this.__someValue = someValue; // explicitly initialized
}
}
// ❌ Incorrect — property may not exist as an own property
class MyNode extends ElementNode {
__someValue?: string; // optional syntax without initialization
constructor(key?: NodeKey) {
super(key);
// __someValue is never assigned → won't be synced via yjs
}
}If you use NodeState for your custom properties, this concern does not apply. NodeState values are synced correctly without relying on constructor-created enumerable properties.
Yjs providers
Setting up the communication between clients, managing awareness information, and storing shared data for offline usage is quite a hassle. Providers manage all that for you and are the perfect starting point for your collaborative app.
See Yjs Website for the list of the officially endorsed providers. Although it's not an exhaustive one.
Packages Lexical Website Docs Concepts Commands (`packages/lexical-website/docs/concepts/commands.md`)
Commands
Commands are a very powerful feature of Lexical that lets you register listeners for events like KEY_ENTER_COMMAND or KEY_TAB_COMMAND and contextually react to them wherever & however you'd like.
This pattern is useful for building Toolbars or complex extensions and nodes such as tables which require special handling for selection, keyboard events, and more.
When registering a command you supply a priority and can return true to mark it as "handled", which stops other listeners from receiving the event. If a command isn't handled explicitly by you, it's likely handled by default in RichTextExtension or PlainTextExtension.
`createCommand(...)`
You can view the core commands in the lexical API reference. Other packages define their own, listed under Commands in each package's API reference, such as TOGGLE_LINK_COMMAND in @lexical/link. If you need a custom command for your own use case, check out the typed createCommand(...) function.
const HELLO_WORLD_COMMAND: LexicalCommand<string> = createCommand('HELLO_WORLD');
editor.registerCommand(
HELLO_WORLD_COMMAND,
(payload: string) => {
console.log(payload); // Hello World!
return false;
},
COMMAND_PRIORITY_EDITOR,
);
editor.dispatchCommand(HELLO_WORLD_COMMAND, 'Hello World!');`editor.dispatchCommand(...)`
Commands can be dispatched from anywhere you have access to the editor such as a Toolbar Button, an event listener, or an extension, but most of the core commands are dispatched from LexicalEvents.ts.
Calling dispatchCommand will implicitly call editor.update to trigger its command listeners if it was not called from inside editor.update.
Don't dispatch a command from inside a read-only context such aseditor.read(fn) or editorState.read(fn). Command listeners usually change
the editor, so Lexical runs them in a separate writable update and
development builds log a warning when they detect this. Dispatch after the
read returns instead.
editor.dispatchCommand(command, payload);The payloads are typed via the createCommand(...) API, but they're usually a DOM event for commands dispatched from an event listener.
Here are some real examples from LexicalEvents.ts.
editor.dispatchCommand(KEY_ARROW_LEFT_COMMAND, event);
// ...
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');And another example from the ToolbarPlugin in our Playground.
const formatBulletList = () => {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND);
};Which is later handled by ListExtension to insert the list into the editor.
editor.registerCommand(
INSERT_UNORDERED_LIST_COMMAND,
() => {
$insertList('bullet');
return true;
},
COMMAND_PRIORITY_LOW,
);`editor.registerCommand(...)`
You can register a command from anywhere you have access to the editor object, but it's important that you remember to clean up the listener with its remove listener callback when it's no longer needed.
The command listener will always be called from an editor.update, so you may use dollar functions. You should not useeditor.update (and never call editor.read(...) or editor.read('force-commit', ...)) synchronously from within a command listener. It is safe to calleditor.read('latest', ...) or editor.getEditorState().read if you need to read the previous state after updates have already been made.
const removeListener = editor.registerCommand(
COMMAND,
(payload) => boolean, // Return true to stop propagation.
priority,
);
// ...
removeListener(); // Cleans up the listener.The easiest way to get clean-up right is to register commands from an
extension's register function and
return the remove callback. The editor calls it when it is disposed. To
register several things, combine their callbacks with mergeRegister from@lexical/utils.
import {LinkExtension, TOGGLE_LINK_COMMAND} from '@lexical/link';
import {COMMAND_PRIORITY_BEFORE_EDITOR, defineExtension} from 'lexical';
export const LinkLoggerExtension = defineExtension({
// The command it observes is handled by LinkExtension
dependencies: [LinkExtension],
name: '@my-app/LinkLogger',
register: (editor) =>
editor.registerCommand(
TOGGLE_LINK_COMMAND,
(payload) => {
console.log('link', payload);
return false; // let the link extension handle it too
},
// LinkExtension handles this command at COMMAND_PRIORITY_EDITOR and
// returns true, so observe it at the BEFORE variant of that priority.
COMMAND_PRIORITY_BEFORE_EDITOR,
),
});A listener that only observes a command, like this one, needs to run before
the listener that handles it. Depending on the extension that handles it and
using the COMMAND_PRIORITY_BEFORE_* variant of its priority does that
without escalating to a higher priority. See
Priorities and ordering below.
In a legacy React plugin, return the registerCommand call from auseEffect instead.
And as seen above and below, registerCommand's callback can return true to signal to the other listeners that the command has been handled and propagation will be stopped.
Here's a simplified example of handling a KEY_TAB_COMMAND from TabIndentationExtension, which is used to dispatch a OUTDENT_CONTENT_COMMAND or INDENT_CONTENT_COMMAND.
editor.registerCommand(
KEY_TAB_COMMAND,
(payload) => {
const event: KeyboardEvent = payload;
event.preventDefault();
return editor.dispatchCommand(
event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND,
);
},
COMMAND_PRIORITY_EDITOR,
);Note that the same KEY_TAB_COMMAND command is registered by LexicalTableSelectionHelpers.ts, which handles moving focus to the next or previous cell within a TableNode, but the priority is high (COMMAND_PRIORITY_HIGH) because this behavior is very important.
Priorities and ordering
Command listeners are called in the following order until a listener returns true:
- From priority highest to lowest (critical, high, normal, low, editor)
- All
COMMAND_PRIORITY_BEFORE_${priority}listeners, most recently registered first - All
COMMAND_PRIORITY_${priority}listeners, in registration order
:::note
The COMMAND_PRIORITY_BEFORE_* priorities make it much easier to override
default behavior without escalating the priority.
:::
It is best practice to use the lowest priority possible, so most commands will
be registered with COMMAND_PRIORITY_EDITOR for the default behavior, then
commands to override that behavior can be registered withCOMMAND_PRIORITY_BEFORE_EDITOR. The higher priorities mostly serve purposes
such as being able to observe-but-not-handle events and to support legacy code
that predates the availability of COMMAND_PRIORITY_BEFORE_*.
A modern lexical app will typically only need theCOMMAND_PRIORITY_BEFORE_EDITOR priority since the
last-registered-called-first ordering is suitable for almost all use cases. The
older priorities without BEFORE can be considered legacy and are primarily
offered for compatibility.
Here is the full ordering of priorities, from lowest to highest:
COMMAND_PRIORITY_EDITORCOMMAND_PRIORITY_BEFORE_EDITORCOMMAND_PRIORITY_LOWCOMMAND_PRIORITY_BEFORE_LOWCOMMAND_PRIORITY_NORMALCOMMAND_PRIORITY_BEFORE_NORMALCOMMAND_PRIORITY_HIGHCOMMAND_PRIORITY_BEFORE_HIGHCOMMAND_PRIORITY_CRITICALCOMMAND_PRIORITY_BEFORE_CRITICAL
Packages Lexical Website Docs Concepts Compared With Prosemirror (`packages/lexical-website/docs/concepts/compared-with-prosemirror.md`)
Compared with ProseMirror
Lexical and ProseMirror are both frameworks for
building rich text editors on top of contentEditable, and both keep an
immutable document model as the source of truth. This page collects the main
differences in one place, for readers who know one and are learning the other.
Document model {#document-model}
Lexical's tree is shaped like the HTML it renders (see
Document Model). This is a deliberate difference from
ProseMirror. ProseMirror's document is also a tree,
and blocks can contain other blocks, but the inline content of a textblock is
a flat sequence of nodes (text and inline nodes) with marks for formatting and
links, and every position in the document is a single integer.
| Lexical | ProseMirror | |
|---|---|---|
| Document | Immutable tree of nodes, one RootNode |
Immutable tree of nodes, one doc node |
| Node identity and navigation | Each node has a runtime key and knows its parent and siblings | Nodes are plain values with no parent links; a resolved position supplies ancestor context |
| Blocks | ElementNode subclasses (paragraph, heading, list, table, …), or a block DecoratorNode (image, embed, …) |
Block nodes defined in the schema |
| Inline formatting | Format flags on each TextNode (bold, italic, code, …) |
Marks on text |
| Links and other inline wrappers | Inline ElementNodes (LinkNode, MarkNode) that contain text nodes |
Marks on text, like formatting |
| Embedded content | DecoratorNode, inline or block, rendered by your framework (for example React) |
Leaf or atom nodes, often with a custom NodeView |
| Several editable regions in one node | Named slots: regions addressed by name, like a card's title, each isolated so editing and selection never cross the boundary |
Child nodes in the order the schema's content expression allows, optionally marked isolating, or a separate editor inside a NodeView |
| Addressing a position | Node key plus offset ({key, offset, type}) |
One integer counted across the whole document |
| Allowed structure | Declared by node classes, enforced with node transforms and normalization | Declared by a schema of content expressions |
| Applying edits | Call node and selection methods inside editor.update() |
Build a transaction from steps that address positions or ranges |
| Rendering | Each node's createDOM()/updateDOM(), applied by the reconciler, and optionally changed per node class by a DOMRenderExtension override |
toDOM in the schema, or a NodeView |
Serialization {#serialization}
Because a Lexical link is an element that already contains its text, it
exports as an <a> around those text nodes. A mark-based model has to find the
adjacent text that shares a mark and group it into one element first.
| Format | Lexical | ProseMirror |
|---|---|---|
| JSON | editorState.toJSON(), restored with editor.parseEditorState() |
doc.toJSON() and Node.fromJSON(schema, json) |
| HTML export | $generateHtmlFromNodes(), using each node's createDOM() or exportDOM() |
DOMSerializer.fromSchema(schema), using toDOM from the schema's nodes and marks |
| HTML import | $generateNodesFromDOM(), using importDOM() or DOMImportExtension rules |
DOMParser.fromSchema(schema), using the schema's parseDOM rules |
| Markdown | @lexical/markdown transformers or @lexical/mdast |
prosemirror-markdown: a markdown-it parser and a serializer with a function per node and mark |
| Clipboard | Copy writes plain text, HTML, and Lexical JSON. Paste prefers the JSON, then HTML, then plain text. | Copy writes HTML and plain text. Paste parses the HTML with the schema's parse rules. |
In the JSON, a Lexical link is a link node with its text nodes aschildren, and bold is a format value on a text node, where
ProseMirror's JSON gives each text node a list of marks. In both editors the
default HTML export reuses the definition that renders the editor
(createDOM() in Lexical, toDOM in ProseMirror), so the two stay in step
unless you override one. See Serialization
for the details of each Lexical format.
History {#history}
Both editors group consecutive edits into one undo step. Lexical'sHistoryExtension merges edits made within delay
(300ms by default) and keeps an unlimited stack unless you set maxDepth.
ProseMirror's history plugin uses newGroupDelay: 500 and depth: 100 by
default, so configExtension(HistoryExtension, {delay: 500, maxDepth: 100})
matches its behavior.
Packages Lexical Website Docs Concepts Decorators (`packages/lexical-website/docs/concepts/decorators.mdx`)
Decorators
Decorator nodes are used when a piece of editor content needs a custom view
instead of a text-only or element-only DOM representation. Common examples are
media embeds, mentions, cards, horizontal rules, or interactive controls that
need application UI inside the editor.
A DecoratorNode still belongs to the EditorState, but its visible UI comes
from decorate(). The DOM element returned by createDOM() is the host that
Lexical reconciles. The decorated value returned by decorate() is rendered
into that host by the framework integration, such as React's decorators in@lexical/react.
When to use a decorator
Use a decorator node when:
- The content is represented by structured data rather than editable text.
- The rendered UI is owned by your application or framework.
- Selection should treat the content as a single node or custom interactive
region. - The node needs a separate DOM import, DOM export, or JSON serialization shape.
Prefer TextNode or ElementNode when the content can be edited directly as
normal text or nested editor content. For example, a custom paragraph style is
usually an ElementNode, while a video embed is a decorator.
Inline and block decorators
DecoratorNode is inline by default. Override isInline() to return false
for block-level content such as horizontal rules, video embeds, or cards.
class VideoNode extends DecoratorNode<ReactNode> {
isInline(): false {
return false;
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): false {
return false;
}
decorate(): ReactNode {
return <VideoPlayer />;
}
}Block decorators often also need custom selection behavior. The built-inHorizontalRuleNode is a useful reference because it is a block decorator that
registers click handling and node selection support.
Registering decorators
Keep a decorator's node registration and behavior in one extension instead of
splitting them across an editor config and a framework plug-in. The extension
can provide the node and register commands, transforms, or listeners that
belong to it:
export const INSERT_VIDEO_COMMAND = /* @__PURE__ */ createCommand<string>(
'INSERT_VIDEO_COMMAND',
);
export const VideoExtension = /* @__PURE__ */ defineExtension({
name: 'example/Video',
nodes: () => [VideoNode],
register(editor) {
return editor.registerCommand(
INSERT_VIDEO_COMMAND,
videoID => {
$insertNodes([$createVideoNode(videoID)]);
return true;
},
COMMAND_PRIORITY_EDITOR,
);
},
});Add that extension to the root editor extension. React applications mount the
result withLexicalExtensionComposer:
const editorExtension = /* @__PURE__ */ defineExtension({
name: 'ExampleEditor',
namespace: 'Example',
dependencies: [VideoExtension],
});
<LexicalExtensionComposer extension={editorExtension}>
{/* toolbar and other editor UI */}
</LexicalExtensionComposer>;The command can then be dispatched from a toolbar, menu, or any other UI:
editor.dispatchCommand(INSERT_VIDEO_COMMAND, videoID);This keeps the configuration and registration together. A framework-independent
decorator whose decorate() returns null can use the same extension shape withbuildEditorFromExtensions
outside React.
State and serialization
Store only serializable data on the node or in NodeState.
The decorated UI should be derived from that data. Do not store React elements,
DOM nodes, functions, Map, Set, or other non-serializable values in node
state.
The example below stores a video id with NodeState and renders a React
component from that id:
const videoIDState = createState('videoID', {
parse: value => (typeof value === 'string' ? value : ''),
});
class VideoNode extends DecoratorNode<ReactNode> {
$config() {
return this.config('video', {
extends: DecoratorNode,
stateConfigs: [{flat: true, stateConfig: videoIDState}],
});
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): false {
return false;
}
decorate(): ReactNode {
return <VideoPlayer videoID={$getState(this, videoIDState)} />;
}
}
export function $createVideoNode(videoID: string): VideoNode {
return $setState($create(VideoNode), videoIDState, videoID);
}NodeState can hold richer serializable shapes as long as the state config
knows how to parse them. Use aStateValueConfig when a
decorator needs structured data such as ids, dimensions, captions, or embed
metadata.
If the node needs to survive copy and paste outside Lexical, implementexportDOM() in addition to JSON serialization. For importing HTML, preferDOMImportExtension for new code.
Static importDOM() remains the legacy alternative, but it is a separate
import mechanism and cannot be combined with DOMImportExtension. Choose one
pipeline; new extension-based editors should contribute their rules throughDOMImportExtension.
Rendering model
createDOM() should create the stable host element for the node. updateDOM()
returns whether Lexical should replace that host when the node changes. Most
decorator nodes return false because the host can stay in place while the
decorated value changes.
decorate() returns the framework-specific rendered value. In React, the
React plugin renders this value with a portal into the host element created bycreateDOM(). Non-React integrations can use the same node data and provide
their own rendering layer.
Keep editor data and UI state separate. The node should store the content that
belongs in the editor state, while transient UI details such as hover state,
loading state, or local component state should live inside the decorated
component.
Decorators do not have to be tied to a UI framework. A DecoratorNode can
return null from decorate() and do its visible work in createDOM() andupdateDOM(), or delegate behavior to extension hooks such asDOMRenderExtension or a mutation listener.HorizontalRuleNode from @lexical/extension is an example of a decorator
node whose behavior can be managed without rendering a React component fromdecorate().
Decorator nodes also combine well with
named slots when surrounding
application UI needs to be mounted in predictable places around the editor. For
advanced DOM ownership, DOMSlot and ElementNode patterns let an extension
place arbitrary DOM inside or around lexical content while still letting
Lexical manage the actual editor children. Use those patterns when the content
inside the custom UI should remain editable by Lexical. The playground'sReviewExtension and ReviewNode on main are a useful example: the node usesgetDOMSlot() for its editable body, while the extension tracks live review
nodes with a mutation listener and renders the surrounding review UI. Use these
patterns only when a plain ElementNode, framework decorator, or portal would
not give enough control.
Packages Lexical Website Docs Concepts Document Model (`packages/lexical-website/docs/concepts/document-model.md`)
Document Model
An editor state holds a tree of nodes. This page explains
how that tree is shaped.
A DOM-like tree
Lexical's node tree is shaped like the HTML it renders. A paragraph is anElementNode whose children are the nodes inside it, and inline structure
nests the same way: a link is a LinkNode element that contains the text
nodes it wraps, just as an <a> contains its text. Each node type decides how
it renders with createDOM() and updateDOM(), so the model maps closely to
the DOM, and a position in the document is a node plus an offset within it.
Here is a small document with a heading, a paragraph containing bold text and
a link, a block image, and a card that has a named title slot as well as
ordinary body children:
flowchart TB
accTitle: The shape of a Lexical document tree
accDescr: The root has four children in order: a heading, a paragraph, an image, and a card. The heading contains the text "Welcome". The paragraph contains the text "Read the ", a bold text node "docs", and a link, which contains the text "here". The image is a block decorator node with no children. The card has a named slot called title, which holds a paragraph containing the text "Card title", and one ordinary child, a paragraph containing the text "Card body".
root["Root"] --> heading["Heading (h1)"]
root --> paragraph["Paragraph"]
root --> image["Image(block DecoratorNode)"]
root --> card["Card(ElementNode)"]
heading --> welcome["Text: 'Welcome'"]
paragraph --> read["Text: 'Read the '"]
paragraph --> docs["Text: 'docs'{ format: bold }"]
paragraph --> link["Link"]
link --> here["Text: 'here'"]
card -.->|"slot: title"| titlePara["Paragraph"]
titlePara --> titleText["Text: 'Card title'"]
card --> body["Paragraph"]
body --> bodyText["Text: 'Card body'"]Solid arrows are ordinary children, which form an ordered list under their
parent and are read with getChildren(). The dashed arrow is a
named slot: a separate channel on the host, keyed by name
instead of position and read with $getSlot(card, 'title'). A slot's value
is the root of its own isolated region, so its getParent() is null and$getSlotHost() leads back to the card. Any ElementNode or DecoratorNode
can host slots, so a decorator such as the image could also expose an
editable caption this way.
Working with the tree
Lexical code navigates the document the way DOM code does, with methods likegetParent(), getChildren(), and getNextSibling(), and each node owns the
DOM it renders. The resemblance is structural, not one
node per HTML element: bold or italic text, for example, is a format on aTextNode, not a separate <strong> or <em> node. See
Nodes for the built-in node types.
Customizing rendering
You can also change how nodes render without subclassing them.DOMRenderExtension from @lexical/html
takes overrides for createDOM, updateDOM, and exportDOM, for one node
class or for every node, such as adding a data- attribute or wrapping a
node's children in another element. Each override calls $next() to get the
default result and adjusts it, so overrides from several extensions compose.
Export overrides only affect HTML export, and createDOM overrides also carry
through to export for nodes that use the default exportDOM. Changes made
while updating the editor's DOM don't; see
Lexical -> HTML for which
hooks reach export.
For how the tree is converted to and from JSON, HTML, and Markdown, see
Serialization. For
how this model differs from a mark-based editor, see
Compared with ProseMirror.
Packages Lexical Website Docs Concepts Dom Events (`packages/lexical-website/docs/concepts/dom-events.md`)
Working with DOM Events
Sometimes, when working with Lexical, it might be necessary or useful for you to attach a DOM Event Listener to the underlying DOM nodes that Lexical controls. For instance, you might want to show a popover when a user mouses over a specific node or open a modal when they click on a node. Either of these use cases (and many others) can be accomplished via native DOM Event Listeners. There are 3 main ways that you can listen for DOM Events on nodes controlled by Lexical:
1. Event Delegation
One way to handle events inside the editor is to set a listener on the editor root element (the contentEditable Lexical attaches to). You can do this using a Root Listener.
function myListener(event) {
// You may want to filter on the event target here
// to only include clicks on certain types of DOM Nodes.
alert('Nice!');
}
const ClickExtension = defineExtension({
name: '@my-app/Click',
register: (editor) =>
editor.registerRootListener((rootElement) => {
if (rootElement === null) {
return;
}
// add the listener to the current root element
rootElement.addEventListener('click', myListener);
// remove the listener when the root element changes - make sure the ref
// to myListener is stable so the removal works and you avoid a memory leak.
return () => rootElement.removeEventListener('click', myListener);
}),
});An extension's register returns a cleanup function, and the editor calls it
when it is disposed, so there is no teardown to write by hand. AddClickExtension to your editor's dependencies to use it.
This can be a simple, efficient way to handle some use cases, since it's not necessary to attach a listener to each DOM node individually.
The addEventListener/removeEventListener pairing above is common enough that the core lexical package exports a registerEventListener(target, type, listener, options?) helper. It attaches the listener and returns a dispose function that removes it, so the example above becomes:
import {defineExtension, registerEventListener} from 'lexical';
const ClickExtension = defineExtension({
name: '@my-app/Click',
register: (editor) =>
editor.registerRootListener((rootElement) =>
// registerEventListener returns the matching removeEventListener cleanup,
// so there's no need to write the teardown by hand.
rootElement === null
? undefined
: registerEventListener(rootElement, 'click', myListener),
),
});It mirrors the addEventListener overloads (so the event type is strongly typed) and composes well with mergeRegister when you need to register several listeners at once.
To attach several listeners to the same target, registerEventListeners(target, listeners, options?) takes a {type: listener} map and returns a single dispose function that removes all of them. Each listener's event argument is still strongly typed per event type, and the optional options argument is shared across every listener:
import {registerEventListeners} from 'lexical';
const removeListeners = registerEventListeners(
rootElement,
{
click: onClick,
keydown: onKeyDown, // receives a KeyboardEvent
},
{capture: true}, // shared by both listeners
);Because options is shared, register a group that needs different options (such as a different capture flag) with a separate registerEventListeners call and combine the results with mergeRegister.
2. Directly Attach Handlers
In some cases, it may be better to attach an event handler directly to the underlying DOM node of each specific node. With this approach, you generally don't need to filter the event target in the handler, which can make it a bit simpler. It will also guarantee that your handler isn't running for events that you don't care about. This approach is implemented via a Mutation Listener.
const registeredElements: WeakSet<HTMLElement> = new WeakSet();
const removeMutationListener = editor.registerMutationListener(nodeType, (mutations) => {
editor.getEditorState().read(() => {
for (const [key, mutation] of mutations) {
const element: null | HTMLElement = editor.getElementByKey(key);
if (
// Updated might be a move, so that might mean a new DOM element
// is created. In this case, we need to add an event listener too.
(mutation === 'created' || mutation === 'updated') &&
element !== null &&
!registeredElements.has(element)
) {
registeredElements.add(element);
element.addEventListener('click', (event: Event) => {
alert('Nice!');
});
}
}
});
});
// teardown the listener - return it from an extension's `register` so the
// editor removes it when it is disposed.
removeMutationListener();Notice that here we don't worry about cleaning up, as Lexical will dereference the underlying DOM nodes and allow the JavaScript runtime garbage collector to clean up their listeners.
3. Use NodeEventPlugin
If you're using React, we've wrapped approach #2 up into a simple React plugin that you can render inside your composer to achieve the same effect, without worrying about the details:
<LexicalExtensionComposer extension={appExtension}>
<NodeEventPlugin
nodeType={LinkNode}
eventType={'click'}
eventListener={(e: Event) => {
alert('Nice!');
}}
/>
</LexicalExtensionComposer>If the editor lives inside a shadow root or an iframe, see
Shadow DOM and iframes for the helper to readevent.target through the shadow boundary — the browser retargets it to the
shadow host on listeners attached above the boundary, so a plainevent.target won't match a node inside the editor.
Packages Lexical Website Docs Concepts Editor State (`packages/lexical-website/docs/concepts/editor-state.md`)
Editor State
Why is it necessary?
With Lexical, the source of truth is not the DOM, but rather an underlying state model
that Lexical maintains and associates with an editor instance.
While HTML is great for storing rich text content it's often "way too flexible" when it comes to text editing.
For example the following lines of content will produce equal outcome:
***Lexical***
***Lexical***
***Lexical***See rendered version!
Of course, there are ways to normalize all these variants to a single canonical form, however this would require DOM manipulation and so re-rendering of the content. And to overcome this we can use Virtual DOM, or State.
On top of that it allows to decouple content structure from content formatting. Let's look at this example stored in HTML:
Why did the JavaScript developer go to the bar? **Because he couldn't handle his *Promise*s**In contrast, Lexical decouples structure from formatting by offsetting this information to attributes. This allows us to have canonical document structure regardless of the order in which different styles were applied.
Here is the same content as a Lexical node tree, printed the way the
tree view shows it (the numbers are node keys):
root
└ (2) paragraph
├ (3) text "Why did the JavaScript developer go to the bar? "
├ (4) text "Because he couldn't handle his " { format: bold }
├ (5) text "Promise" { format: bold, italic }
└ (6) text "s" { format: bold }The paragraph's children are a flat list of text nodes, and the formatting is a
property of each one, so there is only one way to represent this content.
Understanding the Editor State
You can get the latest editor state from an editor by calling editor.getEditorState().
Editor states have two phases:
- During an update they can be thought of as "mutable". See "Updating state" below to
mutate an editor state. - After an update, the editor state is then locked and deemed immutable from there on. This
editor state can therefore be thought of as a "snapshot".
Editor states contain two core things:
- The editor node tree (starting from the root node). See
Document Model for how the tree is shaped. - The editor selection (which can be null).
For example, "Hello world" with a link around "world" and the caret at the
end looks like this:
flowchart TB
accTitle: A simple editor state
accDescr: The root contains a paragraph with a "Hello " text node and a link. The link contains the "world" text node. The selection's anchor and focus both point to offset 5 in "world".
subgraph state["EditorState"]
direction TB
root["Root"] --> paragraph["Paragraph"]
paragraph --> hello["Text: 'Hello '"]
paragraph --> link["Link"]
link --> world["Text: 'world'"]
selection["Selection"] -.->|"caret at offset 5"| world
endeditor.getEditorState() returns the latest committed state. Its toJSON()
method serializes the document tree only; the selection and the runtime node
keys are not included. To restore saved content, pass the JSON toeditor.parseEditorState() and the result to editor.setEditorState(). The
editor must have the same node types registered. See
Serialization for JSON, HTML, and
Markdown.
Here's an example of how you can initialize an editor with saved content and
then persist it. The saved JSON is passed as the editor's $initialEditorState:
import {buildEditorFromExtensions} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {defineExtension} from 'lexical';
// Get the saved content (e.g. loaded from a backend)
const initialEditorState = await loadContent();
const editor = buildEditorFromExtensions(
defineExtension({
$initialEditorState: initialEditorState,
dependencies: [RichTextExtension],
name: '@my-app/editor',
}),
);
editor.setRootElement(document.getElementById('editor'));
// Store the content (e.g. when the user submits a form)
async function onSubmit() {
await saveContent(JSON.stringify(editor.getEditorState()));
}With React, pass the extension to LexicalExtensionComposer. A component
rendered inside it can reach the editor with useLexicalComposerContext():
import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {RichTextExtension} from '@lexical/rich-text';
import {defineExtension} from 'lexical';
import {useMemo} from 'react';
function SaveButton() {
const [editor] = useLexicalComposerContext();
return (
saveContent(JSON.stringify(editor.getEditorState()))}>
Save
);
}
function Editor({initialEditorState}) {
// The editor is recreated whenever this extension changes,
// so keep it stable for the life of the editor.
const extension = useMemo(
() =>
defineExtension({
$initialEditorState: initialEditorState,
dependencies: [RichTextExtension],
name: '@my-app/editor',
}),
[initialEditorState],
);
return (
<LexicalExtensionComposer extension={extension}>
<SaveButton />
</LexicalExtensionComposer>
);
}$initialEditorState is applied once, when the editor is built; changing it
later has no effect on that editor. See "Updating state" below for the proper
way to change editor state after initialization.
$initialEditorState accepts:
- a JSON string or a parsed
SerializedEditorStateobject, passed toeditor.parseEditorState()(as in the example above); - an
EditorStateinstance, applied directly witheditor.setEditorState(); - a function
(editor) => void, run insideeditor.update(...); null, which skips default initialization entirely. Use this with
collaboration so that the Yjs document, not
Lexical, owns the initial state.
Omitting the field (or passing undefined) seeds the root with a default emptyParagraphNode. The two are not interchangeable: null leaves the root with no children,
while undefined produces a single empty line. If your loadContent may yield null orundefined for new documents, coalesce to undefined (e.g. (await loadContent()) ?? undefined)
so the editor still gets the default paragraph rather than the collab-style uninitialized
state.
The legacy LexicalComposer takes the same values as initialConfig.editorState,
except that a function there only runs if the root is still empty.
Updating state
All reads and writes of the document happen inside a synchronous callback:
import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
editor.update(() => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('Hello world'));
$getRoot().append(paragraph);
});
const text = editor.read('force-commit', () => $getRoot().getTextContent());The `$` function convention {#dollar-functions}
Functions whose names start with $, such as $getRoot() and$getSelection(), only work inside one of these callbacks, because they act
on the active editor state. Calling them anywhere else throws an error. The
convention is similar to React Hooks:
| React Hooks | Lexical $ functions |
|
|---|---|---|
| Naming | useFunction |
$function |
| Can only be called | while rendering a component | inside an update or read |
| Can call others of the same kind | ✅ | ✅ |
| Must be synchronous | ✅ | ✅ |
| Must be called unconditionally, in the same order | ✅ | ❌ No such rule |
Command handlers and node transforms already run inside an update, so they
can call $ functions directly.
This works because, while a callback runs, Lexical keeps the active editor and
the active editor state in module-level variables, and $ functions read
them: $getRoot() returns the root of the active state, and $getEditor()
returns the active editor. Because the callback is synchronous, nothing else
can run in between and change them. The same mechanism enforces the
difference between reads and updates. Inside a read, a method that would
change a node throws Cannot use method in read-only mode.
Give your own functions a $ prefix when they call $ functions, so it is
clear where they can be used. The@lexical/rules-of-lexical ESLint rule
checks this convention.
The same rule applies to node objects. Call node methods only inside a read or
update. Every node has a key that identifies it across versions of the editor
state, and node methods use that key to find the latest version of the node.
This is why a node reference taken earlier in an update stays usable after the
node changes. Keys exist only at runtime: they are not serialized, and you
should treat them as opaque.
Because the callbacks are synchronous, do any asynchronous work (fetching
data, awaiting a promise) first, and then enter an update with the result.
Avoid nesting one editor.update() inside another: the inner update does not
run immediately but is queued to run after the outer one. Never start an
update inside a read.
Each update works on a pending copy of the editor state, and updates made in
the same tick are committed and reconciled to the DOM together. See
Updates for the full lifecycle.
Replacing the state
Another way to set state is setEditorState method, which replaces current state with the one passed as an argument.
Here's an example of how you can set editor state from a stringified JSON:
const editorState = editor.parseEditorState(editorStateJSONString);
editor.setEditorState(editorState);:::warning
setEditorState rejects an EditorState that satisfies editorState.isEmpty() — i.e.
the root is the only node and there is no selection. In a development build it throws"setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty."; in production it warns with the same message and recovers by appending
an empty ParagraphNode, so the editor stays usable. The EditorState produced by
initializing with editorState: null and never appending content (typical for a
collaboration document before peers connect) has exactly this shape, so persisting and
reloading such a state hits this path. Note that parseEditorState itself succeeds — the
check lands on the apply step. To avoid relying on the recovery, guard witheditorState.isEmpty() before calling setEditorState, or seed the document with aParagraphNode before serializing.
:::
Reading state
Which state you see depends on how you read it:
- Inside
editor.update()you see the pending state, where your changes
are visible but transforms and reconciliation may not have run yet.editor.read('pending', fn)gives you the same view without allowing
changes. editor.read('force-commit', fn)first commits any pending updates, so it
always sees a consistent, reconciled state. Do not call it inside an update.editor.read(fn)with no mode does the same thing, and is kept for
convenience and backwards compatibility. Inside a mutation or update
listener it does not commit an update that another listener started (that
update commits after the listeners have run, so they observe commits in
order); useeditor.read('latest', fn)there.editor.read('latest', fn)reads the most recently reconciled state without
committing anything, so pending changes are not visible.editorState.read(fn, {editor})reads one particular snapshot, such as theeditorStatean update listener receives. Passing theeditorkeeps$getEditor()and extension lookups working inside the callback.
State update listener
If you want to know when the editor updates so you can react to the changes, you can add an update
listener to the editor, as shown below:
editor.registerUpdateListener(({editorState}) => {
// The latest EditorState can be found as `editorState`.
// To read the contents of the EditorState, use the following API:
editorState.read(
() => {
// Just like editor.update(), .read() expects a closure where you can use
// the $ prefixed helper functions.
},
// Passing the editor keeps $getEditor() and extension lookups working.
{editor},
);
});When are Listeners, Transforms, and Commands called?
There are several types of callbacks that can be registered with the editor that are related to
updates of the Editor State.
| Callback Type | When It's Called |
|---|---|
| Update Listener | After reconciliation |
| Mutation Listener | After reconciliation |
| Node Transform | During editor.update(), after the callback finishes, if any instances of the node type they are registered for were updated |
| Command | As soon as the command is dispatched to the editor (called from an implicit editor.update()) |
Synchronous reconciliation with discrete updates
While commit scheduling and batching are normally what we want, they can sometimes get in the way.
Consider this example: you're trying to manipulate an editor state in a server context and then persist it in a database.
editor.update(() => {
// manipulate the state...
});
saveToDatabase(editor.getEditorState().toJSON());This code will not work as expected, because the saveToDatabase call will happen before the state has been committed.
The state that will be saved will be the same one that existed before the update.
Fortunately, the discrete option for LexicalEditor.update forces an update to be immediately committed.
editor.update(() => {
// manipulate the state...
}, {discrete: true});
saveToDatabase(editor.getEditorState().toJSON());Cloning state
Lexical state can be cloned, optionally with custom selection. One of the scenarios where you'd want to do it
is setting editor's state but not forcing any selection:
// Passing `null` as a selection value to prevent focusing the editor
editor.setEditorState(editorState.clone(null));Packages Lexical Website Docs Concepts Headless (`packages/lexical-website/docs/concepts/headless.md`)
Running Without a Browser
The DOM is only needed to show an editor on screen. Because every read and
update goes through the editor state, Lexical also works where there is no
browser DOM:
- With no DOM at all. An editor that has no root element skips
reconciliation and DOM selection entirely, so you can build one withbuildEditorFromExtensions()and never callsetRootElement(). AddingHeadlessExtensionfrom@lexical/headlessmakes that explicit: attaching a
root element then throws, and nested editors are headless too. Updates,
transforms, listeners, commands, and JSON serialization all still work,
which is enough to process documents on a server, apply changes from a
collaboration backend, or write tests. - With a virtual DOM such as happy-dom or jsdom, for the features that
create DOM nodes, like HTML import and export.withDOM()from@lexical/headless/domruns a callback with a temporary happy-dom window,
for example to convert HTML on a server.
Example: converting saved JSON to HTML on a server
import {buildEditorFromExtensions} from '@lexical/extension';
import {HeadlessExtension} from '@lexical/headless';
import {withDOM} from '@lexical/headless/dom';
import {$generateHtmlFromNodes} from '@lexical/html';
import {RichTextExtension} from '@lexical/rich-text';
import {defineExtension} from 'lexical';
const editor = buildEditorFromExtensions(
defineExtension({
dependencies: [HeadlessExtension, RichTextExtension],
name: '@my-app/server-editor',
}),
);
export function jsonToHtml(json: string): string {
editor.setEditorState(editor.parseEditorState(json));
return withDOM(() => editor.read(() => $generateHtmlFromNodes(editor)));
}The editor must have the same node types registered as the editor that saved
the JSON, which is why it uses the same extensions. JSON and Markdown
conversion do not need withDOM().
`createHeadlessEditor()`
Before extensions, headless editors were created with createHeadlessEditor()
from @lexical/headless. It still works, and it makes DOM-only methods such asregisterRootListener and registerMutationListener throw. Because many
extensions register those listeners, use HeadlessExtension withbuildEditorFromExtensions() in new code.
Packages Lexical Website Docs Concepts History (`packages/lexical-website/docs/concepts/history.md`)
History
Lexical adds support for Undo and Redo through the @lexical/history package —registerHistory, the HistoryExtension extension, or the HistoryPlugin
React component from @lexical/react/LexicalHistoryPlugin. Part of undo and redo requires supporting coalescing of certain dynamic operations such as continuous typing. The below provides background information regarding the continuous typing undo coalescing feature.
Undo Coalescing of Typed Text
TL;DR
When working with text editors, users expect a well polished undo experience. A key part of this is a smooth, well thought out workflow when undoing continuous typing events. For this post, strict typing events may be limited to typing characters, backspacing and forward deleting. Users generally expect that continuous typing should fully undo with a single undo gesture and that they don’t require separate undo gestures for each character.
The algorithm that covers this is “undo coalescing of typed text” and there’s a certain degree of nuance involved. This post covers that nuance while attempting to avoid prescriptive implementation details.
What Kind of Edits Require Coalescing?
As mentioned above, continuous typing should be coalesced into a single undo action. Continuous typing is a loaded phrase and we’ll get into some details below.
There are other types of edits that require undo coalescing such as rapidly resizing an image or rapidly changing the font size of selected text. These latter two examples fall under what might be termed “dynamic operations” and their implementation might vary quite a bit from coalescing of typing.
What Constitutes Continuous Typing?
For the purposes of undo coalescing, continuous typing events are safely limited to these three actions:
- Forward typing characters (heads up, composed character typing discussed later).
- Deleting backwards, e.g. backspace gestures including backspace key presses.
- Forward deletion, e.g. forward delete gestures including Delete key presses.
Any interruption to the above breaks the notion of continuous typing. Note that switching between either of these three actions may itself be considered an interruption of continuous typing or it may not. This ends up being a usability decision. In my opinion, when examining various word processors, switching between these three actions should maintain continuity, because users often make typos or corrections in the flow of typing and therefore would lean towards the whole sequence of typing, backspacing and deleting to be within a single undoable session.
Note that Google Docs and Quip treat a switch between typing, backspacing and deleting as disruptions to the continuous typing sessions.
Start with Selected Text and Followup with Typing
Let’s start with some selected text. The user may have selected the text directly, as a result of pasting text or through some other sequence of steps. When followed by continuous typing, the initial selection should be included along with the following typing events as part of a single coalesced undo session.
Timeouts
Text editing is generally a modeless user experience and so just about any event beyond the above typing actions will disrupt continuous typing. The first and foremost is a timeout.
Quip seems to require continuous typing to occur within 1 second intervals, whereas Google Docs requires about 2 seconds. In my opinion, the timeout may stretch to as long as 3 to 5 seconds. The more time between typing events, the more chance the user has to intentionally input a block of text, a thought or concept.
The use of a timeout is not necessarily required, and the amount of time is certainly up for debate. Collaborative co-editing also makes use of timeouts when grouping text that is merged across clients. Longer timeouts tend to make collaboration a smoother, less disruptive experience, because receiving other client input while expressing one’s thoughts tends to be unhelpful.
Implementers should consider the correct timeout based on their customer expectations and related collaborative features that might share this timeout.
Hard and Soft Returns
Hard returns as generated by pressing the Return key tend to disrupt and thereby reset continuous typing.
For Google Docs and Quip, soft returns as generated by pressing shift+Return key do not terminate continuous typing. This makes sense in that the soft return ends text wrap but does not end the paragraph.
Changing Selections and Key Focus
Any change to the selection by the user should terminate coalescing. This includes:
- Arrow gesture navigation.
- Gestures to select a word, a sentence or a paragraph.
- Any other selection change that does not move the blinking caret to the next or previous character by way of the above mentioned typing events.
Additionally, ending keyboard focus on the editing window, including moving focus to another editing window or a different view within the same window should also terminate coalescing. This also includes setting focus to modal popovers, such as, for example, a hyperlink property editor.
Composed Characters
Character composition occurs when typing diacritics, CJK and even emojis. The process usually involves at least these three phases:
- Start composition.
- Replace composed text with other text and remain composing. This step may occur many times within a single composition session.
- Stop composition either through accepting the text or canceling the latest input.
Coalescing should terminate at start composition. Replacing composed text should generally be coalesced. Stopping composition may or may not reset coalescing depending upon the implementation. Quip appears to try to coalesce multiple composed characters, but disrupts coalescing when switching between composed and non-composed characters. Google Docs appears to disrupt coalescing more frequently. Both coalesce the text replacements.
Auto Correct, Spelling Corrections and Text Substitutions
Text substitution behaves much like composed characters. When the text is transformed, the coalescing should terminate and the transformed text becomes the first coalesced entry in the next undo session.
Copy & Pasting
Any actions like copy and paste typically trigger events that are not part of the 3 continuous typing actions described above. So, copy and paste also terminates coalescing.
In Conclusion
Text undo coalescing greatly improves the usability of undo in that it allows the user to bypass all the intermediate events that went into forming or re-forming text within a paragraph. Given the modeless nature of text editing, limiting continuous typing to strictly three events allows for a robust and clean implementation. Implementers will need tight bottlenecks to trap selection changes, key focus changes as well as changes from collaborative clients. While the above is not complete by any means, I hope readers will find it helpful.
Tuning HistoryExtension for memory and long sessions
HistoryExtension ships with config values chosen for the common case
of a short-lived editor on a richly interactive surface. For
applications that keep an editor mounted for a long time — chat
threads, long documents, anything where the user may produce thousands
of distinct history events without reloading the page — two settings
are worth tuning explicitly.
`maxDepth` — cap the undo stack
By default maxDepth is null, meaning the undo stack is
unbounded. Every separate-history boundary (see "What Constitutes
Continuous Typing?" above) pushes a HistoryStateEntry that retains a
full EditorState snapshot. Snapshots share a copy-on-writeNodeMap, so the marginal cost of any one entry is small, but it is
not zero — and an unbounded stack accumulates a long tail over time.
Set a finite maxDepth to keep the stack at a fixed length. The
extension FIFO-evicts the oldest entries past the cap; older snapshots
become unreachable and can be collected.
import {configExtension, defineExtension} from 'lexical';
import {HistoryExtension} from '@lexical/history';
const editorExtension = defineExtension({
// ...
dependencies: [
// 100 events is a reasonable starting point that supports a deep
// enough undo stack for almost any interactive editing session.
configExtension(HistoryExtension, {maxDepth: 100}),
],
});The cap is also exposed as a writable signal onHistoryExtension.output.maxDepth, so it can be reconfigured at
runtime (the new value applies to future pushes — the current stack
is not retroactively trimmed when the cap is lowered).
`delay` — coalesce more aggressively
delay is the merge window during which adjacent edits collapse into
the current history entry instead of pushing a new one. The default is300ms. A longer delay means fewer history events for the same
typing burst, which:
- reduces the rate at which
undoStackgrows, multiplying the effect
of anymaxDepthcap, and - gives the user undo gestures that match larger semantic chunks of
their writing.
For text-heavy editors a value in the 500–1000ms range is common.
configExtension(HistoryExtension, {delay: 500, maxDepth: 100}),When NOT to cap history
If your editor is short-lived (mounted only for the duration of a
modal, page form, or single message draft) and undo is expected to
reach back to the document's initial state, the default maxDepth: null is fine — the stack will be discarded with the editor instance.
The cap matters when an editor is long-lived and can accumulate
thousands of distinct history events without unmounting.
Packages Lexical Website Docs Concepts Key Management (`packages/lexical-website/docs/concepts/key-management.md`)
Key Management
Keys are a fundamental concept in Lexical that enable efficient state management and node tracking. Understanding how keys work is crucial for building reliable editor implementations.
What are Keys?
The __key property is a unique identifier assigned to each node in the Lexical editor. These keys are:
- Automatically generated by Lexical
- Used to track nodes in the editor state
- Essential for state management and updates
- Immutable during a node's lifecycle
When to Use `__key`?
✅ Correct Usage
Keys should ONLY be used in two specific situations:
- In Node Constructors
class MyCustomNode extends ElementNode {
constructor(someData: string, key?: NodeKey) {
super(key); // Correctly passing key to parent constructor
this.__someData = someData;
}
}- In Static Clone Methods
class MyCustomNode extends ElementNode {
static clone(node: MyCustomNode): MyCustomNode {
return new MyCustomNode(node.__someData, node.__key);
}
}❌ Incorrect Usage
Never use keys in these situations:
// ❌ Don't pass keys between different nodes
const newNode = new MyCustomNode(existingNode.__key);
// ❌ Don't manipulate keys directly
node.__key = 'custom-key';How Lexical Uses Keys
Diagram
The dotted outlines show nodes that are re-used in a zero-copy fashion from one EditorState to the next
graph TB
subgraph s0["Initial State"]
direction TB
m0["NodeMap (v0)"]
m0 -->|Key A| n0a["A (v0)"]
m0 -->|Key B| n0b["B (v0)"]
end
subgraph s1["Create Node C"]
direction TB
style n1a stroke-dasharray: 5 5
style n1b stroke-dasharray: 5 5
m1["NodeMap (v1)"]
m1 -->|Key A| n1a["A (v0)"]
m1 -->|Key B| n1b["B (v0)"]
m1 -->|Key C| n1c["C (v0)"]
end
subgraph s2["Update Node A"]
direction TB
style n2a stroke-dasharray: 5 0
style n2b stroke-dasharray: 5 5
style n2c stroke-dasharray: 5 5
m2["NodeMap (v1)"]
m2 -->|Key A| n2a["A (v1)"]
m2 -->|Key B| n2b["B (v0)"]
m2 -->|Key C| n2c["C (v0)"]
end
s0 -.-> s1 -.-> s2Node Map Structure
The EditorState maintains a Map<NodeKey, LexicalNode> that tracks all nodes. Nodes refer to each other using keys in their internal pointers:
// Internal node structure (not for direct usage)
{
__prev: null | NodeKey,
__next: null | NodeKey,
__parent: null | NodeKey,
// __first, __last and __size are only for ElementNode to track its children
__first: null | NodeKey,
__last: null | NodeKey,
__size: number
}These internal pointers maintain the tree structure and should never be manipulated directly.
Key-Related APIs
- Editor Methodstypescript
// Get node by key const node = $getNodeByKey(key); // Get the DOM element a node key is reconciled to const element = editor.getElementByKey(key); // Get latest version of a node const latest = node.getLatest(); // Get mutable version for updates const mutable = node.getWritable();
Key Lifecycle
NodeKeys are ephemeral and have several important characteristics:
Serialization
- Keys are never serialized
- New keys are generated when deserializing (from JSON/HTML)
- Keys are only meaningful within their EditorState instance
Uniqueness
- Keys are unique within an EditorState
- Current implementation uses serial numbers for debugging
- Should be treated as random and opaque values
- Never logically reused
Keys are used internally by Lexical to:
- Track nodes in the editor state
- Manage node updates and versions
- Maintain referential integrity
- Enable efficient state updates
Common Pitfalls
Key Reuse
typescript// ❌ Never do this function duplicateNode(node: LexicalNode) { return new SameNodeType(data, node.__key); }Manual Key Assignment
typescript// ❌ Never do this node.__key = generateCustomKey();Incorrect Constructor/Clone Implementation
typescript// ❌ Never do this - missing key in constructor class MyCustomNode extends ElementNode { constructor(someData: string) { super(); // Missing key parameter this.__someData = someData; } } // ✅ Correct implementation class MyCustomNode extends ElementNode { __someData: string; constructor(someData: string, key?: NodeKey) { super(key); this.__someData = someData; } static clone(node: MyCustomNode): MyCustomNode { return new MyCustomNode(node.__someData, node.__key); } afterCloneFrom(prevNode: this): void { super.afterCloneFrom(prevNode); this.__someData = prevNode.__someData; } }Node Replacement
typescript// ❌ Never re-use the key when changing the node class const editorConfig = { nodes: [ CustomNodeType, { replace: OriginalNodeType, with: (node: OriginalNodeType) => new CustomNodeType(node.__key), withKlass: CustomNodeType } ] }; // ✅ Correct: Use node replacement configuration const editorConfig = { nodes: [ CustomNodeType, { replace: OriginalNodeType, with: (node: OriginalNodeType) => new CustomNodeType(), withKlass: CustomNodeType } ] };For proper node replacement, see the Node Replacement guide.
Best Practices
- Let Lexical Handle Keystypescript
import {$applyNodeReplacement} from 'lexical'; // Create node helper function export function $createMyCustomNode(data: string): MyCustomNode { return $applyNodeReplacement(new MyCustomNode(data)); }
Testing Considerations
When writing tests involving node keys:
test('node creation', async () => {
await editor.update(() => {
// ✅ Correct: Create nodes normally
const node = new MyCustomNode("test");
// ✅ Correct: Keys are automatically handled
expect(node.__key).toBeDefined();
expect(node.__key).not.toBe('');
});
});Performance Impact
Understanding key management is crucial for performance:
- Keys enable efficient node lookup (O(1))
- Proper key usage prevents unnecessary re-renders
- Lexical's key system optimizes state updates
- Improper key manipulation can cause performance issues
Common Questions
Q: How do I reference a node later?
A: Store a reference to the node. Conventionally, all node methods will use getLatest() or getWritable() which will look up the latest version of that node before reading or writing its properties, which is equivalent to using the key but is type-safe (but may cause errors if you try to use a reference to a node that no longer exists). In some situations it may be preferable to use the key directly, which is also fine.
Q: How do I ensure unique nodes?
A: Let Lexical handle key generation and management. Focus on node content and structure.
Packages Lexical Website Docs Concepts Keyboard Accessibility (`packages/lexical-website/docs/concepts/keyboard-accessibility.md`)
Keyboard Accessibility
Lexical follows the WAI-ARIA Authoring Practices keyboard model. This page documents the contracts that plugin authors and host applications rely on, so a Lexical editor can sit inside a larger keyboard-accessible flow without trapping or surprising the user.
Tab key
By default, Tab inside the editor follows the browser's normal focus order — it moves to the next focusable element on the page. There is no keyboard trap.
The optional TabIndentationExtension (@lexical/extension) takes over Tab and uses it for block indent / outdent. Hosts that mount this extension opt in to that behavior. WCAG 2.1.2 (No Keyboard Trap) still applies — pressing Escape blurs the editor (see below), at which point Tab leaves the editor again.
Escape key
Pressing Escape while the editor has focus calls editor.blur() via the rich-text Escape command handler (registered at COMMAND_PRIORITY_EDITOR). The handler only blurs when the current selection is a range selection; with a NodeSelection (or no selection) it returns false and falls through without blurring. After blur, the next Tab advances to the page's next focusable element — so the editor is not a hard keyboard trap. (TabIndentationExtension consumes Tab only while a range selection is active, and its own source notes that taking over Tab is generally discouraged for keyboard accessibility, so treat WCAG 2.1.2 conformance as something to verify in your integration rather than a guarantee.)
Plugin authors who want to intercept Escape (modal close, dropdown close, etc.) should register their handler at a higher priority and return true to stop the command chain. Common patterns already established in the codebase:
| Priority | Caller | Purpose |
|---|---|---|
HIGH |
Floating link editor | Close the floating UI, keep editor focus |
HIGH |
Equation component (while editing) | Close the equation editor input |
NORMAL |
Comment plugin | Cancel inline comment editing |
LOW |
Image component | Escape out of caption / resize editing: drop the inner selection and restore the NodeSelection on the image decorator |
EDITOR |
Rich text (default) | editor.blur() (range selection only) |
Handlers that return false let the chain continue, so Escape can still fall through to the default blur even after a higher-priority handler runs.
Some host UI binds its close handler directly to a DOM keydown listener rather than going through KEY_ESCAPE_COMMAND — the playground's Modal via window.addEventListener('keydown', ...) and its DropDown via a React onKeyDown on the menu. Those paths run alongside the table above; they do not participate in the editor's command priority chain.
React hooks and `@lexical/a11y` extensions
The keyboard helpers below ship as @lexical/a11y extensions — the extension is the API and does all of the work (event listeners, focus management, the live region). The @lexical/react hooks (useLexicalFocusManagerRef, useLexicalRovingTabIndexRef, useLexicalFocusTrapRef, useLexicalAriaLiveRegion) are thin React adapters that only communicate with their extension: each returns a RefCallback (or an announce function) that drives the extension's signals. Using a hook therefore requires its matching extension to be registered in the editor's extension tree — it is not an optional integration path. Non-React hosts (Svelte, Vue, vanilla DOM) use the extensions directly and skip the hooks.
Toolbar focus jump
The playground uses useLexicalFocusManagerRef (@lexical/react/useLexicalFocusManagerRef) to provide a documented shortcut for moving focus from the editor to the toolbar without tabbing through the page. The hook returns a RefCallback that registers the attached element as a focus-managed toolbar. It listens for Alt+F10 inside the editor and moves focus into the toolbar — preferring the item that currently carries tabindex="0" (the active roving item, when paired with useLexicalRovingTabIndexRef), and otherwise the first focusable item. While focus is in the toolbar, pressing Escape restores both focus and the editor's prior selection via editor.focus(). Multiple toolbars can be registered simultaneously. Requires FocusManagerExtension from @lexical/a11y in the editor's extension tree.
This matches the WAI-ARIA editor menubar pattern and the convention used by Word and CKEditor. Hosts that don't mount useLexicalFocusManagerRef get the default browser flow (Tab in, Tab out).
Toolbar arrow-key navigation
Toolbars that opt into useLexicalRovingTabIndexRef (@lexical/react/useLexicalRovingTabIndexRef) collapse to a single tab stop: arrow keys move between items inside the toolbar; Tab moves past the toolbar as a whole. Items keep tabindex="-1" except the active one (tabindex="0"). The hook returns a RefCallback — multiple containers can be registered simultaneously. Tracks the WAI-ARIA toolbar pattern. Requires RovingTabIndexExtension from @lexical/a11y in the editor's extension tree.
Modal focus trap
useLexicalFocusTrapRef (@lexical/react/useLexicalFocusTrapRef) returns a RefCallback that cycles Tab / Shift+Tab inside a modal container and restores focus to the previously-focused element on deactivation. While active, the trap also installs a document-level focusin listener that pulls any focus landing outside the container back to the first focusable descendant (or the container itself) — so it recovers from the browser routing Tab out to its own chrome. Although several containers can be registered, only one trap should be active at a time: concurrent traps install competing focusin listeners and fight over focus. Escape is not intercepted by the hook — modal owners handle the close key themselves, so the editor's Escape contract above stays predictable. Requires FocusTrapExtension from @lexical/a11y in the editor's extension tree.
Screen reader announcements
useLexicalAriaLiveRegion (@lexical/react/useLexicalAriaLiveRegion) returns a stable announce(message) function. The hook itself mounts no DOM — like the others above, it is a thin adapter that requires AriaLiveRegionExtension from @lexical/a11y in the editor's extension tree. That extension owns the lifecycle of the visually-hidden region (role="status", aria-atomic, aria-live defaulting to polite — configurable via its politeness config): the region is created in the extension's register step and disposed on the editor's teardown, not on the hook's unmount. EditorModeAnnounceExtension (mode transitions via registerEditableListener) and HistoryAnnounceExtension (undo / redo via UNDO_COMMAND / REDO_COMMAND) announce through the same region. Both extensions accept optional message overrides for i18n.
Component ARIA quick reference
Where Lexical already applies ARIA attributes today (set in each component's createDOM / updateDOM, declaratively in the surrounding React tree, or applied imperatively via setAttribute on a managed or portaled element — as the typeahead menu and the live region do):
| Component | role | aria-* attributes |
|---|---|---|
ToolbarPlugin (playground) |
toolbar |
aria-label |
Modal (playground) |
dialog |
aria-modal, aria-labelledby |
LexicalListItemNode (check list leaf) |
checkbox |
aria-checked |
LexicalMenu typeahead popup |
listbox |
aria-label |
| Editor root (while typeahead is open) | — | aria-controls, aria-activedescendant |
EquationNode (playground) |
math |
aria-label |
useLexicalAriaLiveRegion region |
status |
aria-live, aria-atomic |
Custom nodes follow the same pattern: set the attributes in createDOM and re-apply in updateDOM when the state they reflect changes. There is no central ARIA contract on LexicalNode; treat each subclass's createDOM as the source of truth.
Browser notes
Lexical relies on the standard DOM Escape contract — verified on Chrome and Safari (macOS, normal window). Safari in fullscreen intercepts Escape at the browser level to exit fullscreen; that is the browser's contract, not Lexical's.
What this page does not cover
- Touch / mobile screen reader gestures. The hooks above share the same DOM contracts with mobile (TalkBack, VoiceOver iOS) but mobile has not been exercised in this set of changes.
- High contrast / forced-colors theming. See the playground's
index.css@media (forced-colors: active)block for a baseline.
Packages Lexical Website Docs Concepts Listeners (`packages/lexical-website/docs/concepts/listeners.md`)
Listeners
Listeners are a mechanism that lets the Editor instance inform the user when a certain operation has occurred. All listeners follow a reactive pattern where you can do an operation upon something happening in the future. All listeners also return a function that easily allows for the
listener to be unregistered. Below are the different listeners that Lexical supports today:
`registerUpdateListener`
Get notified when Lexical commits an update to the DOM.
const removeUpdateListener = editor.registerUpdateListener(({editorState}) => {
// The latest EditorState can be found as `editorState`.
// To read the contents of the EditorState, use the following API:
editorState.read(() => {
// Just like editor.update(), .read() expects a closure where you can use
// the $ prefixed helper functions.
});
});
// Do not forget to unregister the listener when no longer needed!
removeUpdateListener();The update listener callbacks receives a single argument containing the follow properties:
editorStatethe latest updated Editor StateprevEditorStatethe previous Editor Statetagsa Set of all tags that were passed to the updatedirtyElementsa Map of the NodeKeys of ElementNodes that were marked
dirty, to a boolean that is true when the node was intentionally mutateddirtyLeavesa Set of the NodeKeys of all dirty nodes that are not
ElementNodesmutatedNodesa Map of LexicalNode constructors to aMap<NodeKey, NodeMutation>, or null when no DOM was mutated. Only
populated when at least one MutationListener is registerednormalizedNodes(advanced) the keys of TextNode descendants that were
merged with a sibling by normalization
One thing to be aware of is "waterfall" updates. This is where you might schedule an update inside an update
listener, as shown below:
editor.registerUpdateListener(({editorState}) => {
// Read the editorState and maybe get some value.
editorState.read(() => {
// ...
});
// Then schedule another update.
editor.update(() => {
// ...
});
});The problem with this pattern is that it means we end up doing two DOM updates, when we likely could have
done it in a single DOM update. This can have an impact on performance, which is important in a text editor.
A listener that re-enqueues work without a stop condition is worse than slow.
The editor keeps a cascade budget: once a run of updates re-enqueues more
than 100 times without yielding to the event loop, it discards the entire
pending update queue and warns. Bounded activity never reaches the limit —
the budget is reset from a macrotask, which a genuine runaway starves — but a
true loop silently loses whatever else was queued.
To avoid this, we recommend looking into Node Transforms, which allow you to listen to node changes and
transform them as part of the same given update, meaning no waterfalls!
`registerTextContentListener`
Get notified when Lexical commits an update to the DOM and the text content of the editor changes from
the previous state of the editor. If the text content is the same between updates, no notifications to
the listeners will happen.
const removeTextContentListener = editor.registerTextContentListener(
(textContent) => {
// The latest text content of the editor!
console.log(textContent);
},
);
// Do not forget to unregister the listener when no longer needed!
removeTextContentListener();`registerMutationListener`
Get notified when a specific type of Lexical node has been mutated. There are three states of mutation:
createddestroyedupdated
Mutation listeners are great for tracking the lifecycle of specific types of node. They can be used to
handle external UI state and UI features relating to specific types of node.
If any existing nodes are in the DOM, and skipInitialization is not true, the listener
will be called immediately with an updateTag of 'registerMutationListener' where all
nodes have the 'created' NodeMutation. This can be controlled with the skipInitialization option,
which defaults to false.
const removeMutationListener = editor.registerMutationListener(
MyCustomNode,
(mutatedNodes, { updateTags, dirtyLeaves, prevEditorState }) => {
// mutatedNodes is a Map where each key is the NodeKey, and the value is the state of mutation.
for (let [nodeKey, mutation] of mutatedNodes) {
console.log(nodeKey, mutation)
}
},
{skipInitialization: false}
);
// Do not forget to unregister the listener when no longer needed!
removeMutationListener();`registerEditableListener`
Get notified when the editor's mode has changed. The editor's mode can be changed
via editor.setEditable(boolean).
const removeEditableListener = editor.registerEditableListener(
(editable) => {
// The editor's mode is passed in!
console.log(editable);
},
);
// Do not forget to unregister the listener when no longer needed!
removeEditableListener();`registerDecoratorListener`
Get notified when the editor's decorator object changes. The decorator object contains
all DecoratorNode keys -> their decorated value. This is primarily used with external
UI frameworks.
const removeDecoratorListener = editor.registerDecoratorListener(
(decorators) => {
// The editor's decorators object is passed in!
console.log(decorators);
},
);
// Do not forget to unregister the listener when no longer needed!
removeDecoratorListener();`registerRootListener`
Get notified when the editor's root DOM element (the content editable Lexical attaches to) changes. This is primarily used to
attach event listeners to the root element. The root listener function is executed directly upon registration and then on any subsequent update.
const removeRootListener = editor.registerRootListener(
(rootElement, prevRootElement) => {
//add listeners to the new root element
//remove listeners from the old root element
},
);
// Do not forget to unregister the listener when no longer needed!
removeRootListener();Packages Lexical Website Docs Concepts Named Slots (`packages/lexical-website/docs/concepts/named-slots.md`)
Named Slots
:::warning
Named slots are experimental. The APIs described here are tagged@experimental, may change without a major version bump, and the
serialization and collab formats should be treated as unstable until the
feature is stabilized.
:::
Named slots let a single host node (an ElementNode or a DecoratorNode) own
several isolated editable regions addressed by name — a Card's title, a
PullQuote's quote and attribution — inside the host's own EditorState.
Each region takes its own caret and formatting, never merges across the
boundary, and Cmd+A doesn't spill into the rest of the document.
Before named slots, the usual answer was a nested editor per region: every
region lives in its own EditorState, so moving nodes between regions and
keeping history and collab in sync all require serialization and extraeditor.update passes. The other node shapes don't fit either: plain
ElementNode children share one undivided linked list (Backspace at a region
start merges it into the previous region), and a DecoratorNode is atomic, so
Lexical can't own selection, collab, or serialization inside it. With named
slots, editing a slot is just editing the one tree.
:::note
Named slots are the model-level generalization of the rendering concept
behind $getDOMSlot /ElementDOMSlot in the
DOM rendering documentation. Every
ElementNode already has one unnamed children channel, and $getDOMSlot
controls where that channel's content attaches in the node's DOM. Named
slots are additional, explicitly-named channels alongside it — symmetric in
that each renders into a controllable location in the host's DOM — but they
also carry the model-level semantics the unnamed channel doesn't: isolation
(the virtual shadow root below), a separate NodeKey map, and their own
serialization and collaboration.
:::
The Model
A host keeps a second child channel, a Map of slot name to NodeKey,
separate from its ordinary linked-list children. A slot value has its slot
host pointer set and getParent() === null, with exactly one of the two
non-null: getParent() stops at the slot boundary, and you climb out only
through $getSlotHost().
The slot link itself acts as a virtual invisible shadow root between the
host and the value. Isolation is structural rather than a convention — an
accidental boundary crossing surfaces as a thrown invariant, not silent
corruption.
In the DOM, each value renders synchronously into a keyless<div data-lexical-slot="<name>"> container parked slots-first in the host
DOM as a hidden placeholder (display: none), in a canonical order
derived from the host class (see slot order). Nothing is
visible until the host explicitly attaches the container somewhere (see
Rendering) — mirroring howgetDOMSlot gives an element
control over where its linked-list children render.
Hosts and Values
Any ElementNode or DecoratorNode can host slots. An ElementNode host can mix
slots with ordinary children (a Card with a title slot plus regular body
paragraphs); a DecoratorNode host has no children channel, so slots are its
only editable content (a PullQuote that is otherwise atomic).
A slot value is any non-inline ElementNode or DecoratorNode. The value's
shape decides the editing model:
- A bare block (for example a ParagraphNode) behaves as a single-line
field with no extra wrapper in the model, the JSON, or the DOM: its virtual
scope holds exactly one block, so Enter is a no-op (hosts may map it to
focus movement) and a multi-block paste flattens to inline content the way
an<input>sanitizes its value — line breaks stripped, block-only
decorators dropped. - A shadow-root container (an ElementNode whose
isShadowRoot()returnstrue) behaves as a multi-block region with normal block editing inside.
Declaring and Setting Slots
A host class declares its slot names in $config(); values are
attached with $setSlot:
import {
$create,
$createParagraphNode,
$setSlot,
ElementNode,
} from 'lexical';
class CardNode extends ElementNode {
$config() {
return this.config('card', {extends: ElementNode, slots: ['title']});
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): boolean {
return false;
}
}
function $createCardNode(): CardNode {
const card = $create(CardNode);
// Single-line title: the bare paragraph IS the slot value. An empty
// paragraph is an empty field; to seed default text, append a non-empty
// TextNode (empty TextNodes are eliminated during reconciliation).
$setSlot(card, 'title', $createParagraphNode());
// Ordinary body child, edited like any other block.
return card.append($createParagraphNode());
}For a multi-block region, use a shadow-root container as the value:
class SlotContainerNode extends ElementNode {
$config() {
return this.config('slot-container', {extends: ElementNode});
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): boolean {
return false;
}
isShadowRoot(): boolean {
return true;
}
}
$setSlot(
pullQuote,
'quote',
$create(SlotContainerNode).append(
$createParagraphNode().append($createTextNode('First block')),
$createParagraphNode().append($createTextNode('Second block')),
),
);The core API surface (all exported from lexical):
$setSlot(host, name, node)— place a value into a named slot, replacing
any existing value under that name. Move semantics, mirroringappend:
the value is detached from any current parent or slot first. Throws if the
value is inline or if the name is one of the reserved prototype keys
(__proto__,constructor,prototype). Slotting a node into its own
descendant would form a cycle; this is checked (and throws) in development
only — in production it behaves like appending an ancestor through the
children channel, which is likewise unguarded.$getSlot(host, name)— the value under a name, ornull.$getSlotNames(host)— the host's slot names in canonical order.$removeSlot(host, name)— detach the value under a name (the subtree is
garbage-collected unless you reattach it elsewhere).$getSlotHost(node)— the host a value is slotted into, ornull.$getSlotNameWithinHost(node)— the name of the slot
a value occupies on its host (the reverse direction of$getSlotHost), ornullwhen the node is not a slot value.$getSlotFrame(node)— the innermost slot value containing a node (the
"frame" whose virtual shadow root scopes editing), ornullwhen the node
is not inside any slot.$getSelectionSlotFrame(selection)— the slot frame a
selection lives in, ornullwhen it is outside any slot. Selection-driven
exporters walk this frame instead of the root's children, because a
selection inside a slot never contains its host and a root walk would
export nothing. Works for every selection type, not justRangeSelection.$isSlotHost(node)/$isSlotChild(node)— type guards
for theSlotHostNode/SlotChildNodeinterfaces.
Slot Order
Slot order is canonical and derived, never stored. Names declared in$config() (slots: ['quote', 'attribution']) sort first in declaration
order; undeclared names sort after them, lexicographically by UTF-16 code
unit (plain JavaScript string comparison, locale-independent). $setSlot
re-canonicalizes on every write, so documents normalize on load and
concurrent collaborative additions converge to the same order on every
client. If presentation order matters, declare the names.
Rendering
The reconciler always renders every slot subtree synchronously, but into a
hidden placeholder container — visibility is the host's explicit decision.
There are three ways to attach a slot, all sharing the same contract
(attaching moves the container to the target, a no-op when it is already
there, and reveals it; the container renders as a normal block):
Synchronously in-lexical: register a
$getSlotTargetElementDOMRenderMatch
override for the host's node class (a
DOM render override, not a node
method — this is an advanced hook). The reconciler consults it whenever
it creates or reconciles the slot's container and attaches/reveals
within the same commit — no listener or framework hop. ReturninghostDomreveals the slot in its default slots-first position:tsimport {domOverride, DOMRenderExtension} from '@lexical/html'; import {configExtension, defineExtension} from 'lexical'; export const CardExtension = defineExtension({ dependencies: [ configExtension(DOMRenderExtension, { overrides: [ domOverride([CardNode], { // Reveal the title in its default slots-first position within // the same commit that renders it. Returning an element from // deeper inside the host's DOM attaches it there instead; // $next() defers to lower-priority overrides (default: null, // a hidden placeholder). $getSlotTargetElement: (node, slotName, hostDom, $next, editor) => hostDom, }), ], }), ], name: 'card', nodes: [CardNode], });Imperatively:
mountSlotContainer(editor, nodeKey, slotName, target)
andunmountSlotContainer(editor, nodeKey, container)
fromlexicalare the framework-independent primitives (e.g. from a
mutation listener,
which fires after the commit).mountSlotContainerresolves the container
against the committed editor state
(editor.getEditorState()), so
the model it reads matches the reconciled DOM it reveals;unmountSlotContainertakes the container you already hold and only touches
the DOM:tsimport {mountSlotContainer} from 'lexical'; // e.g. inside an extension's register(editor): const unregister = editor.registerMutationListener( CardNode, (mutations) => { for (const [nodeKey, mutation] of mutations) { if (mutation === 'destroyed') { continue; } const hostDom = editor.getElementByKey(nodeKey); if (hostDom !== null) { // Mounting in place: the placeholder is already parked in the // host DOM, so this just reveals it in its slots-first position. // Any element within the host's DOM works as a target. mountSlotContainer(editor, nodeKey, 'title', hostDom); } } }, {skipInitialization: false}, );unmountSlotContainer(editor, nodeKey, container)is the reverse: it
hides the container and parks it back in the host DOM, for when a mount
target goes away while the host remains.From React chrome: the
useLexicalSlotRef
hook from@lexical/react/useLexicalSlotRefwraps the imperative pair and returns a
ref that mounts a slot's container into your component — the usual choice
for a DecoratorNode host'sdecorate()chrome (whose containers are
opted back intocontentEditableautomatically, since the decorator DOM
is non-editable):
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useLexicalSlotRef} from '@lexical/react/useLexicalSlotRef';
function PullQuoteComponent({nodeKey}: {nodeKey: NodeKey}) {
const [editor] = useLexicalComposerContext();
const quoteRef = useLexicalSlotRef<HTMLDivElement>(editor, nodeKey, 'quote');
const attributionRef = useLexicalSlotRef<HTMLDivElement>(
editor,
nodeKey,
'attribution',
);
return (
);
}A contentEditable=false ElementNode shell can host React chrome the same
way (slot containers opt into editing whenever the host DOM is
non-editable): the playground's Review demo portals chrome into the host DOM,
attaches the author slot with useLexicalSlotRef, drives an interactive
star-rating widget that persists to NodeState, and applies the identical
hidden-then-attach technique to its getDOMSlot children element. Such a shell should callsetDOMUnmanaged(dom) increateDOM — the portal and the
attach moves mutate the shell's children from outside the reconciler, and
the marker gives the shell the same mutation-observer exemption a
DecoratorNode's DOM has.
Editable state
A slot rendered inside a non-editable host (a DecoratorNode, or acontentEditable=false element shell) would not track the editor's editable
state on its own, so the reconciler gives its container an explicitcontentEditable that follows editor.isEditable(), and re-renders the islands
whenever setEditable toggles — so a read-only editor's slots are not left
editable. No extension is required. Slots always follow the editor; there is
currently no way for a slot to override its own editable state.
A host that attaches its own editable island which is not a slot container —
for example the Review demo's getDOMSlot children element inside itscontentEditable=false shell — opts it into the same behavior with$markSlotEditable(element, editor),
re-applying it from updateDOM so an editable toggle reaches the island.
Editing Behavior
- Selection never crosses a slot boundary. Selections are clamped to the
anchor's slot frame at every entry point (DOM resolution,$setSelection,
and point mutation), so a mouse drag and ashift+arrowacross the
boundary land on the same clamped result. A focus in a slot of a host is
moved past the whole host, or to the edge of the host's own children when
the selection starts inside the host. - Deletion stops at the boundary. Backspace at the start of a slot and
forward-Delete at its end are no-ops instead of merging across the virtual
shadow root. Cmd+Ascopes to the slot frame when the caret is inside one; outside
slots the default handlers keep the legacy whole-document behavior.
Progressive expansion (block → enclosing slot frame → document on repeated
presses) is provided by the opt-inSelectBlockExtension
from@lexical/extension.- Generic block conversion skips slot values. A slot value has no
parent — its up-link is$getSlotHost— so selection-driven block
converters ($setBlocksType,$wrapNodes,$insertList/$removeList,
the markdown block shortcuts) treat it as ineligible and do nothing rather
than replace it: the slot assignment is managed by the node or extension
that owns the slot. CallingLexicalNode.replacedirectly on a slot value
throws; reassign the slot with$setSloton its host instead. - Slots stay bound to their host through
replace. Replacing a slot
host (host.replace(other)) does not transfer its slots to the
replacement — slots are not necessarily portable across node types, so the
slot map travels with the node, never with its position. If the replaced
host is reattached in the same update (the$wrapNodeInElement
pattern) it keeps its slots; if it stays detached, its slot subtrees are
garbage-collected with it. The same applies to anything built onreplace:
converting a slot host with$setBlocksTypeproduces a slot-less
replacement block. Move a slot onto another host explicitly with$setSlot. - A NodeSelection of an element carries its children. Copy and export
of a whole-host NodeSelection (e.g. a chrome click that selects "the whole
Card") include the host's body children even though they aren't in the
selection themselves — the old shell-only output made cut silently lossy.
This applies only to NodeSelection; a partial RangeSelection over the host
keeps per-child slicing.
Traversal is intentionally asymmetric
Content reads include slot subtrees, slots-first:getTextContent(),getAllTextNodes(),
and the $dfsWithSlots
family in @lexical/utils count slot content for search, copy, and
accessibility. Navigation excludes them:getChildren(),getFirstDescendant()
and friends stay linked-list-only, so caret movement never walks into a slot
by accident. Choose $dfs or$dfsWithSlots depending on whether "this subtree" should mean the
navigable tree or all content.
Serialization
JSON serialization is automatic in both directions. A host's slots serialize
under the reserved $slots key onSerializedLexicalNode
(a sibling of
NodeState's reserved '$' key), keyed by slot name:
{
"type": "card",
"version": 1,
"$slots": {
"title": {"type": "paragraph", "children": [], "version": 1}
},
"children": []
}Parsing re-attaches each subtree with $setSlot and throws if $slots
appears on a node that cannot host slots. The $ prefix keeps the
framework-owned key from colliding with a subclass that already serializes aslots property of its own.
HTML serialization is opt-in per host, like NodeState: the exporter never
descends into slots on its own. A host's exportDOM can emit each slot into
a wrapper using$appendNodeToHTML from@lexical/html, and a
DOM import rule on the host's
distinguishing markup maps the wrappers back through $setSlot.
Collaboration
Slots sync on both the V1 and V2 Yjs bindings through a per-slot-diffedY.Map stored under the reserved __slots attribute key on the host's shared
type (the channel reuses the host's __slots field name, which is already
excluded from the property sync). Hosts with declared slots create that map
eagerly, so two clients
concurrently setting different slot names for the first time merge
per-entry instead of racing. Malformed or hostile remote entries are
validated and skipped.
:::caution
Pre-slots clients receiving slot data from upgraded peers will error rather
than render it. Enabling slots in long-lived shared documents should be
gated on all participants running a slots-aware version; new clients
tolerate unknown slot data going forward.
:::
Reserved Names
Adding slots reserves a few identifiers that custom node subclasses should
not define for their own purposes:
- the
__slotsand__slotHostfields on ElementNode / DecoratorNode — the__slotsname is also the collab attribute key for the slots channel, so it
is reserved on the Yjs shared type too; - the
$slotsserialized JSON key.
Current Limitations
- The caret / NodeCaret APIs throw across a slot boundary ("no common
ancestor"); slot-aware caret traversal is a planned follow-up. - Nested slots (a slot whose host is itself slotted) are only handled one
level deep by the runtime selection comparator. - The mixed-version collab caveat above.
Packages Lexical Website Docs Concepts Node Cloning (`packages/lexical-website/docs/concepts/node-cloning.md`)
Node Cloning
Understanding how to properly clone and modify nodes is crucial for working with Lexical. This guide explains the different cloning mechanisms and when to use each one.
Types of Node Cloning
Lexical provides several ways to clone nodes, each serving a different purpose:
static clone()- Internal API for state management$copyNode()- Public API for creating new nodesgetWritable()- Public API for node modifications
The `clone` Method
What is `clone`?
The clone method is a static method that creates the next version of a node. A node that declares $config() has one synthesized for it and need not implement it — a hand-written clone is only required for a node that does not use $config(), or whose constructor has required arguments. Either way, it's important to understand that this is an internal API used by Lexical's state management system.
class MyCustomNode extends ElementNode {
static clone(node: MyCustomNode): MyCustomNode {
// ✅ Correct implementation
return new MyCustomNode(node.__someData, node.__key);
}
}When is `clone` Used?
Internal State Management
- Used by Lexical internally
- Part of the editor's state update system
- Called by
getWritable()
Node Mutations
typescript// Example of a node method implementation class MyCustomNode extends ElementNode { setData(data: string): this { const self = this.getWritable(); self.__data = data; return self; } } node.setData("new data");
When NOT to Use `clone`?
// ❌ Never do this
function $duplicateNode(node: MyCustomNode) {
return MyCustomNode.clone(node); // Don't call clone directly
}The `afterCloneFrom` Method
What is `afterCloneFrom`?
afterCloneFrom(prevNode) runs on every clone, right after it is constructed,
and copies over whatever the constructor did not. The base implementation
carries the node's links to its parent and siblings and its
NodeState, and ElementNode and TextNode carry their own
built-in properties such as format, style, and indent. A property that is
copied neither by the constructor nor by some afterCloneFrom silently
reverts to its default on the node's next write.
When you don't need it
With the modern APIs you usually don't write one at all:
- Serialization schema. A property declared as a field in a
$configjsonschema
(withField(stringValue(), {field: '__label'})) is carried across the
clone for you, because the schema says where it is stored. Available in
Lexical v0.51.0 and later. - NodeState. State created with
createStateand stored with$setState
is carried by the baseafterCloneFrom, so a node whose extra data lives
only in NodeState needs nothing.
// At module scope, above the class, so TypeScript checks it against the class
const calloutSchema = nodeSchema<CalloutNode>()({
label: withField(stringValue(), {field: '__label'}),
});
class CalloutNode extends ElementNode {
__label: string = '';
// No afterCloneFrom: `__label` is declared as a field in calloutSchema, so
// it is carried across every clone.
$config() {
return this.config('callout', {extends: ElementNode, json: calloutSchema});
}
}When you still need it
Override afterCloneFrom for a property that neither of those covers: a
field that isn't in the node's schema, one declared only through accessor
methods (see
Carrying properties across a clone),
or any property of a node written without a schema. Always callsuper.afterCloneFrom(prevNode) first so the inherited properties and
NodeState are still carried:
class MyCustomNode extends ElementNode {
__data: string = '';
afterCloneFrom(prevNode: this): void {
super.afterCloneFrom(prevNode);
this.__data = prevNode.__data;
}
}Like clone, afterCloneFrom is called by Lexical; don't call it yourself
except through super.
Using `$copyNode`
What is `$copyNode`?
$copyNode is the public API for creating a copy of a node with a new key. Use this when you need to create a duplicate node.
By default, all properties and NodeState will be copied to the new node, and then resetOnCopyNodeFrom will be called to
allow the node to optionally reset certain properties (and NodeState configured with resetOnCopyNode: true) to defaults
(such as the checked state of a ListItemNode).
// ✅ Correct: Using $copyNode
const copy = $copyNode(existingNode);When to Use `$copyNode`?
- Creating Duplicatestypescript
// ✅ Correct: Duplicating a node const duplicate = $copyNode(originalNode); someParent.append(duplicate);
Using `getWritable`
What is `getWritable`?
getWritable is an internal API used within node method implementations to get a mutable version of a node. Node consumers should use the node's public methods instead.
// ✅ Correct: Implementation of a node method
class MyCustomNode extends ElementNode {
setData(data: string): this {
const self = this.getWritable();
self.__data = data;
return self;
}
}
// ✅ Correct: Using the node
const node = new MyCustomNode();
node.setData("new value");Common Patterns
Modifying Nodes
// ✅ Correct: Modifying a node
function $updateNodeData(node: MyCustomNode, newData: string): MyCustomNode {
return node.setData(newData);
}
// ❌ Incorrect: Don't clone manually
function $updateNodeDataWrong(node: MyCustomNode, newData: string): MyCustomNode {
const clone = MyCustomNode.clone(node);
clone.setData(newData);
return clone;
}Copying Nodes
// ✅ Correct: Copying a node
function $duplicateNode(node: MyCustomNode): MyCustomNode {
return $copyNode(node);
}
// ❌ Incorrect: Don't use clone
function $duplicateNodeWrong(node: MyCustomNode): MyCustomNode {
return node.constructor.clone(node);
}Performance Considerations
Referential Integrity
getWritable()ensures proper EditorState updates with new clones- Prevents "orphaned" nodes that won't be rendered
State Management
typescriptclass MyCustomNode extends ElementNode { setData(data: string): this { const self = this.getWritable(); self.__data = data; // Properly tracked by editor return self; } }
Testing
test('node modification', async () => {
await editor.update(() => {
const node = new MyCustomNode("test");
node.setData("new data");
// ✅ Correct: Use $copyNode for duplication
const copy = $copyNode(node);
});
});Related Concepts
- Editor State - How cloning affects editor state
- Nodes - Core concepts about Lexical nodes
Common Questions
Q: How do I duplicate a node?
A: Use $copyNode(node) to create a new copy with a new key.
Q: When should I use clone?
A: Never directly. Use $copyNode() or getWritable() instead.
Packages Lexical Website Docs Concepts Node Replacement (`packages/lexical-website/docs/concepts/node-replacement.md`)
Node Replacement
Node Replacement allow you to replace all instances of a given node in your editor with instances of a subclass.
Use Case
:::note
In earlier versions of this documentation, "Node Replacement" was called "Node Overrides".
We've changed the name to match the terms used in the implementation.
:::
:::tip
If your use case only requires adding ad-hoc data to existing nodes, you may be able to use the NodeState API instead of subclassing and node replacement.
:::
Some of the most commonly used Lexical Nodes are owned and maintained by the core library. For example, ParagraphNode, HeadingNode, QuoteNode, List(Item)Node etc - these are all provided by Lexical packages, which provides an easier out-of-the-box experience for some editor features, but makes it difficult to override their behavior. For instance, if you wanted to change the behavior of ListNode, you would typically extend the class and override the methods. However, how would you tell Lexical to use your ListNode subclass in ListExtension instead of using the core ListNode? That's where Node Replacement can help.
Node Replacement allow you to replace all instances of a given node in your editor with instances of a different node class. This can be done through the nodes of an extension:
const CustomParagraphExtension = defineExtension({
name: '@my-app/CustomParagraph',
nodes: () => [
// Don't forget to register your custom node separately!
CustomParagraphNode,
{
replace: ParagraphNode,
with: (node: ParagraphNode) => {
return $createCustomParagraphNode();
},
withKlass: CustomParagraphNode,
},
],
});In the snippet above,
replace: Specifies the core node type to be replaced.with: Defines a transformation function to replace instances of the original node to the custom node.withKlass: This option ensures that behaviors associated with the original node type work seamlessly with the replacement. For instance, node transforms or mutation listeners targeting ParagraphNode will also apply to CustomParagraphNode when withKlass is specified. Without this option, the custom node might not fully integrate with the editor's built-in features, leading to unexpected behavior.
Once this is done, Lexical will replace all ParagraphNode instances with CustomParagraphNode instances. One important use case for this feature is overriding the serialization behavior of core nodes. Check out the full example below.
Node Replacement Example
This example demonstrates using Node Replacement to replace all ParagraphNode with a CustomParagraphNode that overrides createDOM.
Packages Lexical Website Docs Concepts Node State (`packages/lexical-website/docs/concepts/node-state.md`)
NodeState
The NodeState API introduced in v0.26.0 allows arbitrary state to be added
ad-hoc to any node in a way that participates with reconciliation, history,
and JSON serialization.
Use Case
NodeState allows your application to define keys that can be stored on
any node with automatic JSON support.
:::tip
You can even add node state to the RootNode to store document-level metadata.
:::
With a combination of NodeState and other APIs such as
Listeners or
Transforms you can
likely shape the editor to meet your needs without having to do much
Node Customization.
Even when you are subclassing nodes, using NodeState instead of additional
properties to store the node's data can be more efficient
and will save you from writing a lot of boilerplate in the constructor,
updateFromJSON, and exportJSON.
Usage
createState
createState
creates a
StateConfig
which defines the key and configuration for your NodeState value.
The key must be locally unique, two distinct StateConfig must not have the
same string key if they are to be used on the same node.
Typical usage will look something like this:
const questionState = createState('question', {
parse: (v) => (typeof v === 'string' ? v : ''),
});The required parse function serves two purposes:
- It provides a type-safe and runtime-safe way to parse values that were
serialized to JSON - When called with
undefined(or any invalid value) it should return some
default value (which may beundefinedornullor any other value you
choose)
In this case, the question must be a string, and the default is an
empty string.
See the
createState
API documentation for more details, there are other optional settings
that you may want to define particularly if the value is not a primitive
value such as boolean, string, number, null, or undefined.
:::tip
Instead of writing parse functions by hand, use the
serialization schema combinators
from lexical, such as stringValue, numberValue, enumValue,arrayValue, objectValue, and nullable. They are the same ones a node's$config JSON schema uses. A schema is itself a parse function, so it can be
passed directly as parse:
import {arrayValue, createState, stringValue} from 'lexical';
const questionState = createState('question', {parse: stringValue()});
const tagsState = createState('tags', {parse: arrayValue(stringValue())});Because parse is just a function, the combinators also compose with schema
libraries such as zod, ArkType,
Effect, and Valibot. Wrap a
library's validator in a parse function that returns a default for invalid
input, or use transformValue to refine what a combinator parsed.
:::
$getState
$getState gets the
NodeState value from the given node, or the default if that key was never
set on the node.
const question = $getState(pollNode, questionState);See also
$getStateChange
if you need an efficient way to determine if the state has changed on two
versions of the same node (typically used in updateDOM, but may be useful in
an update listener or mutation listener).
$setState
$setState sets the
NodeState value on the given node.
const question = $setState(
pollNode,
questionState,
'Are you planning to use NodeState?',
);:::tip
The last argument is a ValueOrUpdater, just like with React's useState
setters. If you use an updater function and the value does not change,
the node and its NodeState won't be marked dirty.
:::
Serialization
The NodeState for a node, if any values are set to non-default values, is
serialized to a record under a single
NODE_STATE_KEY
which is equal to '$'.
{
"type": "poll",
"$": {
"question": "Are you planning to use NodeState?"
}
}:::tip
By default, it is assumed that your parsed values are JSON serializable,
but for advanced use cases you may use values such as Date, Map, or Set
that need to be transformed before JSON serialization. See the
StateValueConfig
API documentation.
:::
Flat serialization with `$config`
Nodes that declare a StateConfig in $config
with flat: true lift that key to the top level of the serialized node
instead of nesting it under '$'. This makes the JSON shape identical
to a legacy node that stored the value as a __property instance
variable, so existing payloads continue to round-trip after the node is
migrated to NodeState.
For example, a ColoredNode that extends TextNode and declares a flatcolor state (see Extending TextNode with $config):
$config() {
return this.config('colored', {
extends: TextNode,
stateConfigs: [{flat: true, stateConfig: colorState}],
});
}serializes as:
{
"type": "colored",
"text": "hello",
"color": "red"
}A non-flat (default) state on the same node would instead appear under'$':
{
"type": "colored",
"text": "hello",
"$": {
"color": "red"
}
}In both cases, a key is only emitted when the current value is not equal
to the default returned by its parse function — see
Efficiency.
:::note
Don't reuse a flat key that a superclass already serializes (e.g. text
on TextNode).
:::
Upgrading a legacy JSON property to NodeState
A node that stores data as a __property instance variable with customexportJSON/importJSON/updateFromJSON can be migrated to NodeState
with flat: true without changing its serialized JSON shape.
Before — ColoredNode with a __color property and hand-written
serialization (see the full legacy example in
Extending TextNode with $config):
export type SerializedColoredNode = Spread<
{color?: string},
SerializedTextNode
>;
export class ColoredNode extends TextNode {
__color: string;
constructor(text: string = '', color: string = DEFAULT_COLOR, key?: NodeKey) {
super(text, key);
this.__color = color;
}
static getType(): string {
return 'colored';
}
static clone(node: ColoredNode): ColoredNode {
return new ColoredNode(node.__text, node.__color, node.__key);
}
static importJSON(serializedNode: SerializedColoredNode) {
return new ColoredNode().updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode: SerializedColoredNode) {
const self = super.updateFromJSON(serializedNode);
self.__color =
typeof serializedNode.color === 'string'
? serializedNode.color
: DEFAULT_COLOR;
return self;
}
exportJSON(): SerializedColoredNode {
return {
...super.exportJSON(),
color: this.__color === DEFAULT_COLOR ? undefined : this.__color,
};
}
}After — same on-the-wire JSON, no hand-written serialization:
const colorState = createState('color', {
parse: (v) => (typeof v === 'string' ? v : DEFAULT_COLOR),
});
export class ColoredNode extends TextNode {
$config() {
return this.config('colored', {
extends: TextNode,
stateConfigs: [{flat: true, stateConfig: colorState}],
});
}
}No exportJSON, importJSON, updateFromJSON, clone, orafterCloneFrom override is needed: $config installs clone andimportJSON, and the base exportJSON/updateFromJSON/afterCloneFrom
on LexicalNode/TextNode already round-trip NodeState. Read the color
via $getState(node, colorState) and write it via$setState(node, colorState, value) instead of node.__color.
:::tip
For a seamless migration, keep the JSON key name the same — for a flat
state, that's the first argument to createState. Migrating from a
non-flat NodeState to a flat NodeState also works: state that appears
under the $ (NODE_STATE_KEY) is still parsed even when the state is
configured as flat, and the flat value takes precedence when both are
present.
:::
Efficiency
NodeState uses a copy-on-write scheme to manage each node's state. If
none of the state has changed, then the NodeState instance will be
shared across multiple versions of that node.
:::info
In a given reconciliation cycle, the first time a Lexical node is marked dirty
via getWritable will create a new instance of that node. All properties
of the previous version are set on the new instance. NodeState is stored
as a single property, and no copying of the internal state is done
until the NodeState itself is marked writable.
:::
When serializing to JSON, each key will only be stored if the value
is not equal to the default value. This can save quite a lot of space
and bandwidth.
Parsing and serialization is only done at network boundaries, when
integrating with JSON or Yjs. When a value changes from an external
source, it is only parsed once the first time it is read.
Values that do not come from external sources are not parsed, and
values that are not used are never parsed.
Capabilities
Current:
- Allows you to define and add state to any node
- Serializes that state automatically in the node's JSON, supporting
versioning and copy+paste - Works with the reconciler, TextNodes with differing state will not
be implicitly merged - @lexical/yjs support, NodeState will be automatically synchronized
like any other property - NodeState values that are not used will simply pass-through, making
it a bit easier for situations where multiple configurations are used
on the same data (e.g. older and newer versions of your editor,
a different set of plugins based on context, etc.). - Pre-registration system for nodes to declare expected state
and serialize them as top-level properties (flat) with$config
(see #7260). - Can be integrated with
DOMRenderExtension for editor
rendering and HTML export - Can be integrated with
DOMImportExtension for HTML import
Future:
- Does not yet support direct integration with Yjs, e.g.
you can not store a Y.Map as a NodeState value
(see #7293)
Node State Style Example
This example demonstrates an advanced use case of storing a style object on TextNode using NodeState.
Packages Lexical Website Docs Concepts Nodes (`packages/lexical-website/docs/concepts/nodes.mdx`)
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
Nodes
Base Nodes
Nodes are a core concept in Lexical. Not only do they form the visual editor view, as part of the EditorState, but they also represent the
underlying data model for what is stored in the editor at any given time. Lexical has a single core based node, called LexicalNode that
is extended internally to create Lexical's five base nodes:
RootNodeLineBreakNodeElementNodeTextNodeDecoratorNode
Of these base nodes, three of them can be extended to create new types of nodes:
ElementNodeTextNodeDecoratorNode
[`RootNode`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalRootNode.ts)
There is only ever a single RootNode in an EditorState and it is always at the top and it represents thecontenteditable itself. This means that the RootNode does not have a parent or siblings. It can not be
subclassed or replaced.
- To get the text content of the entire editor, you should use
rootNode.getTextContent(). - To avoid selection issues, Lexical forbids insertion of text nodes directly into a
RootNode.
Semantics and Use Cases
Unlike other ElementNode subclasses, the RootNode has specific characteristics and restrictions to maintain editor integrity:
Non-extensibility
TheRootNodecannot be subclassed or replaced with a custom implementation. It is designed as a fixed part of the editor architecture.Exclusion from Mutation Listeners
TheRootNodedoes not participate in mutation listeners. Instead, use a root-level or update listener to observe changes at the document level.Compatibility with Node Transforms
While theRootNodeis not "part of the document" in the traditional sense, it can still appear to be in some cases, such as during serialization or when applying node transforms. A node transform on theRootNodewill be called at the end of every node transform cycle. This is useful in cases where you need something like an update listener that occurs before the editor state is reconciled.Document-Level Metadata
If you are attempting to use theRootNodefor document-level metadata (e.g., undo/redo support), use the NodeState API.
By design, the RootNode serves as a container for the editor's content rather than an active part of the document's logical structure. This approach simplifies operations like serialization and keeps the focus on content nodes.
[`LineBreakNode`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalLineBreakNode.ts)
You should never have '\n' in your text nodes, instead you should use the LineBreakNode which represents'\n', and more importantly, can work consistently between browsers and operating systems.
[`ElementNode`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts)
Used as parent for other nodes, can be block level (ParagraphNode, HeadingNode) or inline (LinkNode).
Has various methods which define its behaviour that can be overridden during extension (isInline, canBeEmpty, canInsertTextBefore and more)
[`TextNode`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalTextNode.ts)
Leaf type of node that contains text. It also includes few text-specific properties:
formatany combination ofbold,italic,underline,strikethrough,code,highlight,subscript,superscript,lowercase,uppercaseandcapitalizemodetoken- acts as immutable node, can't change its content and is deleted all at oncesegmented- its content deleted by segments (one word at a time), it is editable although node becomes non-segmented once its content is updated
stylecan be used to apply inline css styles to text
[`DecoratorNode`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalDecoratorNode.ts)
Wrapper node to insert arbitrary view (component) inside the editor. Decorator node rendering is framework-agnostic and
can output components from React, vanilla js or other frameworks.
See Decorators for guidance on when and how to use them.
Node Properties
A custom node stores its own properties as fields on the instance. By
convention the field names start with __ (double underscore) to show that
they are private and should not be accessed directly. We use __ instead of_ because some build tooling mangles and minifies single _ prefixed
properties to improve code size, which breaks down if you're exposing a node
to be extended outside of your build.
In Lexical v0.51.0 and later, declare each property in the node's $config
with a json
serialization schema.
The schema states the property's JSON name, the field it is stored in, and the
values it accepts, and from that Lexical implements exportJSON,updateFromJSON, importJSON, clone, and afterCloneFrom for you. Values
must be JSON serializable: null, number, string, boolean, and plain
objects and arrays of those.
If a property should be readable or changeable from outside the node, give itget*() and set*() methods. They must call getLatest() andgetWritable(), which keep the node consistent with Lexical's immutable
editor state.
import {ElementNode, nodeSchema, stringValue, withField} from 'lexical';
const myCustomNodeSchema = nodeSchema<MyCustomNode>()({
foo: withField(stringValue(), {field: '__foo'}),
});
class MyCustomNode extends ElementNode {
__foo: string = '';
$config() {
return this.config('my-custom', {
extends: ElementNode,
json: myCustomNodeSchema,
});
}
setFoo(foo: string): this {
// getWritable() creates a clone of the node
// if needed, to ensure we don't try and mutate
// a stale version of this node.
const self = this.getWritable();
self.__foo = foo;
return self;
}
getFoo(): string {
// getLatest() ensures we are getting the most
// up-to-date value from the EditorState.
const self = this.getLatest();
return self.__foo;
}
}The constructor must support being called with no arguments, which $config,clone, and collaboration rely on. Give every field a default, as __foo
has above, rather than declaring it optional (__foo?: string): a
freshly constructed instance needs the property to exist for
collaboration to sync
it. You can still define your $create* functions with required arguments.
:::tip
To add a property to a node class you don't own, such as ParagraphNode, or
to attach ad-hoc data to any node without writing a subclass, use
NodeState instead.
:::
Legacy: node properties without a serialization schema
Before v0.51.0, $config only implemented clone and importJSON. A node
with its own fields also has to implement exportJSON, updateFromJSON, andafterCloneFrom by hand, and each one has to call its super method:
import {
ElementNode,
type LexicalUpdateJSON,
type SerializedElementNode,
} from 'lexical';
interface SerializedMyCustomNode extends SerializedElementNode {
foo?: string;
}
class MyCustomNode extends ElementNode {
__foo: string = '';
$config() {
return this.config('my-custom', {extends: ElementNode});
}
afterCloneFrom(prevNode: this): void {
super.afterCloneFrom(prevNode);
this.__foo = prevNode.__foo;
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedMyCustomNode>,
): this {
const self = super.updateFromJSON(serializedNode);
return typeof serializedNode.foo === 'string'
? self.setFoo(serializedNode.foo)
: self;
}
exportJSON(): SerializedMyCustomNode {
const serializedNode: SerializedMyCustomNode = super.exportJSON();
const foo = this.getFoo();
if (foo !== '') {
serializedNode.foo = foo;
}
return serializedNode;
}
// setFoo() and getFoo() as above
}For nodes written without $config at all, see Legacy: custom nodes without $config.
Creating custom nodes with a serialization schema
:::caution Experimental
This feature became available in the lexical v0.51.0 release and is still
considered experimental. Its implementation may change in any release
without a deprecation period.
:::
A node's properties have to be propagated three times: exported to JSON,
imported from untrusted JSON, and carried across a clone.$config's json option replaces all three with a declarative schema.
import {
ElementNode,
enumValue,
nodeSchema,
numberValue,
stringValue,
withField,
} from 'lexical';
const calloutNodeSchema = nodeSchema<CalloutNode>()({
// Each property *is* a field: exported by reading it, imported by assigning
// it, with no method call on either side.
label: withField(stringValue(), {field: '__label'}),
level: withField(numberValue(1, {integer: true, max: 3, min: 1}), {
field: '__level',
}),
tone: withField(enumValue(['info', 'warning', 'danger']), {field: '__tone'}),
});
export class CalloutNode extends ElementNode {
__label: string = '';
__level: number = 1;
__tone: 'info' | 'warning' | 'danger' = 'info';
$config() {
return this.config('callout', {
extends: ElementNode,
// highlight-next-line
json: calloutNodeSchema,
});
}
createDOM(): HTMLElement {
return document.createElement('aside');
}
updateDOM(): false {
return false;
}
}Stating the domain (all possible values the property may inhabit) as data
is the prerequisite that enables all of the new features. In the initial
release, we have:
- Safer JSON import - any missing or invalid properties are parsed as
the default value, instead of potentially silently corrupting the node's
state - Less boilerplate - all of the boilerplate for
exportJSON,updateFromJSONandafterCloneFromis handled automatically. Declaring a
property as a field says where it is stored, which is exactly what a clone
has to carry; a property declared through accessor methods names no field, so
a node with one of those still writes its ownafterCloneFrom. See
Carrying properties across a clone. - Performance - a
@lexical/compilercode generator is used to produce
optimized implementations of export, import and clone for performance that is
indistinguishable (or better) than a hand-written implementation. - Property Tests - the
@lexical/fast-checkpackage can be used to
generate arbitrary valid serialized nodes for thorough testing of your
custom nodes. It's never been easier to find edge cases, automatically.
In the future, this declarative schema can be used to assist in supporting
additional protocols, improving developer tools, porting to other
language/platforms, etc.
The combinators (stringValue, numberValue, enumValue, nullable,optional, arrayValue, unionValue, withField, withAccessors, …) and
the rules for properties that are not plain fields are covered in
Serialization & Deserialization.
For performance reasons, you should prefer serialization schema over NodeState
when the property is inherent to the node type and not ad-hoc.
Backwards compatible return types
That declaration is the whole of the node's serialization. exportJSON,updateFromJSON and afterCloneFrom are all derived from it.
Their types stay the ones the node inherits: exportJSON() is typed asSerializedElementNode here, whatever it actually writes.
Declaration merging can be used to provide a backwards compatible type
interface, for nodes where the exportJSON() return type might have been
treated as a public API:
export type SerializedCalloutNode = Spread<
{label: string; level: number; tone: 'info' | 'warning' | 'danger'},
SerializedElementNode
>;
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface CalloutNode {
exportJSON(compact?: false): SerializedCalloutNode;
exportJSON(compact: boolean): SerializedPartial<SerializedCalloutNode>;
updateFromJSON(serializedNode: LexicalParseJSON<SerializedCalloutNode>): this;
}For backwards compatibility, these interfaces are provided for Lexical's
exported node classes. We do not recommend maintaining this type boilerplate
unless you have code that requires it.
Creating custom nodes with $config and NodeState
In Lexical v0.33.0, a new method for defining custom nodes was added to reduce
boilerplate and add features used by the
NodeState API.
The following section shows how the previous examples would be refactored to
use the latest functionality, reducing boilerplate.
Note that since these example use NodeState and $config, they will
automatically get full and correct implementations of the following methods:
static clonestatic importJSONupdateFromJSONafterCloneFromexportJSON
Best Practices
The constructor of any custom node must have zero required arguments.
This is required for@lexical/yjssupport,$createsupport, and allows the
boilerplate staticcloneandimportJSONmethods to be eliminated.- ✅
constructor(text: string = '', key?: NodeKey) - ❌
constructor(text: string, key?: NodeKey)
- ✅
Declaring the node's own properties with a
json
serialization schema,
or storing ad-hoc data in NodeState, allows the boilerplateafterCloneFrom,exportJSON, andupdateFromJSONmethods to be
eliminated.
Extending `ElementNode` with $config
Below is an example of how you might extend ElementNode:
import {
$create,
type EditorConfig,
ElementNode,
type LexicalNode,
} from 'lexical';
export class CustomParagraph extends ElementNode {
// highlight-start
$config() {
return this.config('custom-paragraph', {extends: ElementNode});
}
// highlight-end
createDOM(): HTMLElement {
// Define the DOM element here
const dom = document.createElement('p');
return dom;
}
updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
// Returning false tells Lexical that this node does not need its
// DOM element replaced with a new one from createDOM.
return false;
}
}import {
$create,
type EditorConfig,
ElementNode,
type LexicalNode,
// highlight-next-line
type SerializedLexicalNode,
} from 'lexical';
export class CustomParagraph extends ElementNode {
// highlight-start
static getType(): string {
return 'custom-paragraph';
}
static clone(node: CustomParagraph): CustomParagraph {
return new CustomParagraph(node.__key);
}
static importJSON(serializedNode: SerializedLexicalNode): CustomParagraph {
return new CustomParagraph().updateFromJSON(serializedNode);
}
// highlight-end
createDOM(): HTMLElement {
// Define the DOM element here
const dom = document.createElement('p');
return dom;
}
updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
// Returning false tells Lexical that this node does not need its
// DOM element replacing with a new copy from createDOM.
return false;
}
}It's also good etiquette to provide some $ prefixed utility functions for
your custom ElementNode so that others can easily consume and validate nodes
are that of your custom node. Here's how you might do this for the above example:
export function $createCustomParagraphNode(): CustomParagraph {
return $create(CustomParagraph);
}
export function $isCustomParagraphNode(
node: LexicalNode | null | undefined
): node is CustomParagraph {
return node instanceof CustomParagraph;
}Extending `TextNode` with $config
/* Detailed source-code truncated for AI context efficiency. *//* Detailed source-code truncated for AI context efficiency. */Extending `DecoratorNode` using $config
import {
// highlight-start
$create,
$getState,
$getStateChange,
$setState,
// highlight-end
DecoratorNode,
type EditorConfig,
type LexicalNode,
// highlight-next-line
createState,
} from 'lexical';
// highlight-start
const idState = createState('id', {
parse: (value) => (typeof value === 'string' ? value : ''),
});
// highlight-end
export class VideoNode extends DecoratorNode<ReactNode> {
// highlight-start
$config() {
return this.config('video', {
extends: DecoratorNode,
stateConfigs: [{flat: true, stateConfig: idState}],
});
}
// highlight-end
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): false {
return false;
}
decorate(): ReactNode {
// highlight-next-line
return <VideoPlayer videoID={$getState(this, idState)} />;
}
}
export function $createVideoNode(id: string): VideoNode {
// highlight-next-line
return $setState($create(VideoNode), idState, id);
}
export function $isVideoNode(
node: LexicalNode | null | undefined,
): node is VideoNode {
return node instanceof VideoNode;
}import {
DecoratorNode,
type EditorConfig,
type LexicalNode,
// highlight-start
type SerializedLexicalNode,
type Spread,
// highlight-end
} from 'lexical';
// highlight-start
export type SerializedVideoNode = Spread<
{
id: string;
},
SerializedLexicalNode
>;
// highlight-end
export class VideoNode extends DecoratorNode<ReactNode> {
// highlight-start
__id: string;
static getType(): string {
return 'video';
}
static clone(node: VideoNode): VideoNode {
return new VideoNode(node.__id, node.__key);
}
static importJSON(serializedNode: SerializedVideoNode): VideoNode {
return new VideoNode(serializedNode.id).updateFromJSON(serializedNode);
}
constructor(id: string, key?: NodeKey) {
super(key);
this.__id = id;
}
exportJSON(): SerializedVideoNode {
return {...super.exportJSON(), id: this.__id};
}
// highlight-end
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): false {
return false;
}
decorate(): ReactNode {
return <VideoPlayer videoID={this.__id} />;
}
}
export function $createVideoNode(id: string): VideoNode {
// highlight-next-line
return $applyNodeReplacement(new VideoNode(id));
}
export function $isVideoNode(
node: LexicalNode | null | undefined,
): node is VideoNode {
return node instanceof VideoNode;
}In React, the editor rendered by LexicalExtensionComposer (and, in legacy setups, RichTextPlugin or PlainTextPlugin) calls React.createPortal(reactDecorator, element) for each DecoratorNode,
where the reactDecorator is what is returned by DecoratorNode.prototype.decorate,
and the element is an HTMLElement returned by DecoratorNode.prototype.createDOM.
Sharing `$config` across an abstract base class
A concrete node records its string type in $config. An abstract base class
has no concrete type of its own, but it can still declare configuration that
is shared with all of its concrete subclasses — for example a $transform, or
required NodeState — by keying its $config with a
well-known symbol instead of a string. By convention this isSymbol.for(<ClassName>):
import {ElementNode} from 'lexical';
// Abstract base — it has no concrete node `type` of its own.
export class BaseBoxNode extends ElementNode {
$config() {
return this.config(Symbol.for('BaseBoxNode'), {
extends: ElementNode,
// Registered for this class and every subclass.
$transform: (node: BaseBoxNode): void => {
// ...
},
});
}
}
// Concrete subclass — inherits the shared $transform.
export class RedBoxNode extends BaseBoxNode {
$config() {
return this.config('red-box', {extends: BaseBoxNode});
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): false {
return false;
}
}The descriptive, globally-registered symbol keeps the configuration easy to find
in a debugger and can never collide with a real node type.
Nominal typing for structurally-identical nodes
TypeScript's type system is structural: two classes with the same members are
mutually assignable even when one is declared as a subclass of the other. So a
node subclass that adds no type-distinguishable members over its base — for
example a node that only overrides methods with identical signatures — is
structurally identical to its base, despite being a distinct class. The built-inTabNode (which extends TextNode) is one such node. Because the two are
structurally identical, TypeScript considers TextNode assignable to TabNode,
so without a nominal distinction the negative branch of an instanceof-style
type guard like $isTabNode() would collapse to never:
import {$isTabNode, type TextNode} from 'lexical';
function $example(node: TextNode) {
if ($isTabNode(node)) {
return;
}
// `node` is correctly narrowed to `TextNode` here.
node.getFormat();
}You get this distinction automatically by declaring the superclass withextends in $config — which is exactly what TabNode does:
import {TextNode} from 'lexical';
export class TabNode extends TextNode {
$config() {
// highlight-next-line
return this.config('tab', {extends: TextNode});
}
}Lexical records each node's own type in the $config return type along theextends chain. That makes the classes nominally distinct: the base is no
longer assignable to the subclass (so the guard narrows correctly) while the
subclass remains assignable to the base. It works at every level of the
hierarchy — a subclass of TabNode is likewise distinct from TabNode — it is
type-only with no runtime cost, and there is nothing to declare on the class
beyond the extends you already provide.
Legacy: custom nodes without `$config`
This is how custom nodes were written before $config (Lexical v0.33.0). It still works, but is maintained only for backwards compatibility, and the legacy interface may be removed before Lexical 1.0, so prefer the sections above for new code. Every one of these methods is something $config writes for you.
As mentioned above, Lexical exposes three base nodes that can be extended.
Did you know? Nodes such as
ElementNodeare already extended in the core by Lexical, such asParagraphNodeandRootNode!
Extending `ElementNode`
Below is an example of how you might extend ElementNode:
import {ElementNode, LexicalNode} from 'lexical';
export class CustomParagraph extends ElementNode {
static getType(): string {
return 'custom-paragraph';
}
static clone(node: CustomParagraph): CustomParagraph {
return new CustomParagraph(node.__key);
}
createDOM(): HTMLElement {
// Define the DOM element here
const dom = document.createElement('p');
return dom;
}
updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
// Returning false tells Lexical that this node does not need its
// DOM element replacing with a new copy from createDOM.
return false;
}
}It's also good etiquette to provide some $ prefixed utility functions for
your custom ElementNode so that others can easily consume and validate nodes
are that of your custom node. Here's how you might do this for the above example:
export function $createCustomParagraphNode(): CustomParagraph {
return $applyNodeReplacement(new CustomParagraph());
}
export function $isCustomParagraphNode(
node: LexicalNode | null | undefined
): node is CustomParagraph {
return node instanceof CustomParagraph;
}Extending `TextNode`
export class ColoredNode extends TextNode {
__color: string;
constructor(text: string, color: string, key?: NodeKey) {
super(text, key);
this.__color = color;
}
static getType(): string {
return 'colored';
}
static clone(node: ColoredNode): ColoredNode {
return new ColoredNode(node.__text, node.__color, node.__key);
}
createDOM(config: EditorConfig): HTMLElement {
const element = super.createDOM(config);
element.style.color = this.__color;
return element;
}
updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
const isUpdated = super.updateDOM(prevNode, dom, config);
if (prevNode.__color !== this.__color) {
dom.style.color = this.__color;
}
return isUpdated;
}
}
export function $createColoredNode(text: string, color: string): ColoredNode {
return $applyNodeReplacement(new ColoredNode(text, color));
}
export function $isColoredNode(
node: LexicalNode | null | undefined,
): node is ColoredNode {
return node instanceof ColoredNode;
}Extending `DecoratorNode`
export class VideoNode extends DecoratorNode<ReactNode> {
__id: string;
static getType(): string {
return 'video';
}
static clone(node: VideoNode): VideoNode {
return new VideoNode(node.__id, node.__key);
}
constructor(id: string, key?: NodeKey) {
super(key);
this.__id = id;
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): false {
return false;
}
decorate(): ReactNode {
return <VideoPlayer videoID={this.__id} />;
}
}
export function $createVideoNode(id: string): VideoNode {
return $applyNodeReplacement(new VideoNode(id));
}
export function $isVideoNode(
node: LexicalNode | null | undefined,
): node is VideoNode {
return node instanceof VideoNode;
}In React, the editor rendered by LexicalExtensionComposer (and, in legacy setups, RichTextPlugin or PlainTextPlugin) calls React.createPortal(reactDecorator, element) for each DecoratorNode,
where the reactDecorator is what is returned by DecoratorNode.prototype.decorate,
and the element is an HTMLElement returned by DecoratorNode.prototype.createDOM.
The rest of the boilerplate
When using this method of extension, it is also required to implement the
following methods:
static clone(always - this is already in the above examples)static importJSON(always)updateFromJSON(if any custom properties are defined)afterCloneFrom(if any custom properties are defined that are not carried over from static clone)exportJSON(if any custom properties are defined)
Without $config, all nodes must have static getType(), static clone(), and static importJSON() methods.
Lexical uses the type to be able to reconstruct a node back with its associated class prototype
during deserialization (important for copy + paste!). Lexical uses cloning to ensure consistency
between creation of new EditorState snapshots.
Here is a node with one custom property and all of these methods written by hand:
/* Detailed source-code truncated for AI context efficiency. */Packages Lexical Website Docs Concepts One Lexical Per App (`packages/lexical-website/docs/concepts/one-lexical-per-app.md`)
sidebar_label: One Lexical per app
One Lexical per app
lexical is effectively a singleton within one app: an app must resolve
exactly one copy of lexical, and one copy of each @lexical/* package it
uses. Two copies in the same bundle do not merely waste bytes — they break the
editor in ways whose error messages point nowhere near the real cause.
The practical consequence, covered in Publishing a library
below: a library built on Lexical declares lexical and the @lexical/*
packages it imports as peerDependencies, never as dependencies. The
application brings its own Lexical.
:::note
This page is about npm peerDependencies in your package.json. Extensions
have their own, unrelated notion of
peer dependencies — optional links between
extensions inside one editor.
:::
The boundary is the app, not the page
Several self-contained apps can share a page, each with its own bundled copy of
Lexical, and that is fine — a CMS page with two independently deployed widgets
on it, or a micro-frontend setup, does not have a bug just because two copies oflexical are loaded. Lexical anticipates this: isLexicalEditor() is aninstanceof check specifically "to prevent issues with multiple embedded
Lexical installations", and an editor is tagged on its own root element rather
than in any page-global registry, so each app's editors resolve to that app's
Lexical and ignore the others.
What those apps cannot do is interoperate. Two copies have no compatible API
surface between them, so treat each as a sealed island:
- do not pass nodes, editors,
EditorStateobjects, selections or commands
across the boundary — pass serialized JSON or HTML, which is data rather than
identity; - a plugin, node package or shared editor config must be bundled with the app it
serves, not shared as a live import between apps; @lexical/reactcontext does not cross the boundary either, since the context
object itself comes from one copy.
The rest of this page is about the failure case: two copies inside one app,
where code from copy A is expected to work with an editor from copy B.
Why one copy per app
Lexical keeps state at module scope, and identifies things by reference rather
than by name. Both assumptions hold only within a single copy of the module.
- The active editor is module state.
activeEditor,activeEditorState
andisReadOnlyModelive at module scope inLexicalUpdates.ts, and every$function reads them.editor.update()in copy A sets copy A's variables;
a$function imported from copy B reads copy B's, which are stillnull
(Editor State). - Node classes are compared by identity. Node registration and the
instanceof LexicalNodechecks compare class references. A node that extendsLexicalNodefrom copy A is not, as far as copy B is concerned, aLexicalNodeat all — even though both classes have the same name and come
from the same published version (Nodes). - Commands are object identities.
createCommand()returns a fresh{type}
object, andregisterCommand/dispatchCommandmatch on that object, not on
itstypestring. Two copies oflexicalexport two distinctFORMAT_TEXT_COMMANDobjects, so a dispatch from one never reaches a listener
registered with the other (Commands).
On top of that, two copies are usually two versions, which is API drift inside
a single editor: half your plugins were compiled against a different API and
serialization format than the editor running them, and the mismatch surfaces
only at runtime, on the paths that happen to differ.
Symptoms of a duplicated Lexical
The errors are unhelpfully literal — a class that plainly does implementgetType() is reported as not implementing it, because the check is running
against the other copy:
LexicalNode: Node FooNode does not implement .getType().
Create node: Type heading in node HeadingNode does not match registered node
HeadingNode with the same type
createEditor: nodes[0] FooNode (type foo) is not a constructor that subclasses
LexicalNode from the lexical package used by this editor (0.50.0)
Unable to find an active editor state. State helpers or node methods can only be
used synchronously during the callback of editor.update(), editor.read(), or
editorState.read(). Detected on the page: 0 compatible editor(s) with version
0.50.0 and incompatible editors with versions 0.49.0That last sentence is Lexical telling you directly that it found editors built
from another copy — a version different from its own, or the same version marked(separately built, likely a bundler configuration issue). Note that this
diagnostic scans the whole page, so a genuinely separate app that ships its own
Lexical is counted there too; it is evidence of a bug only when those editors
were supposed to be part of your app.
Silent failures look like: a command that dispatches but never fires its
listener, $getSelection() returning null inside an update, or a custom node
rendering as plain text after a copy/paste. Real reports of this failure mode:
#4225 and
#7819.
Publishing a library
If you publish a package that imports from lexical or @lexical/* — a plugin,
a node package, a shared editor config, an in-house wrapper in a monorepo — put
those packages in peerDependencies, and in devDependencies so you can still
build and test:
{
"name": "my-lexical-plugin",
"peerDependencies": {
"lexical": ">=0.50.0",
"@lexical/react": ">=0.50.0",
"@lexical/utils": ">=0.50.0"
},
"devDependencies": {
"lexical": "0.50.0",
"@lexical/react": "0.50.0",
"@lexical/utils": "0.50.0"
}
}Notes on the ranges:
- Lexical is pre-1.0, so
^0.50.0means>=0.50.0 <0.51.0— a caret peer range
locks your users out of the next release. Prefer an open>=range (or an
explicit>=0.50.0 <1.0.0) so an app can upgrade Lexical without waiting for
you. - Mark a peer optional with
peerDependenciesMeta
when the import is behind an opt-in entry point, the way this repo's own
packages marktypescriptoptional. - Do not add
lexicaltodependencies"just so it installs". That is exactly
what pins a second copy undernode_modules/your-lib/node_modules/lexicalas
soon as your range and the app's range disagree.
Application setup
The application owns the version, and every Lexical package it installs moves
together:
Install
lexicaland each@lexical/*package you use at the same
version. Published@lexical/*packages depend on the exact matchinglexicalversion (the monorepo'sworkspace:*is rewritten to an exact
version at publish time), so@lexical/[email protected]beside[email protected]
guarantees a nested second copy.Verify there is only one per app, from the app root:
shnpm ls lexical @lexical/react # or: pnpm why lexical / yarn why lexicalMore than one resolved version, or a path with a nested
node_modules/lexical
in it, is the bug.In a monorepo or a linked-package setup (
npm link,pnpm link, a Yalc
copy), the linked library resolves Lexical from its ownnode_modulesand
duplicates are the default rather than the exception. Deduplicate explicitly:
hoist the dependency to the workspace root, or force resolution with
pnpmoverrides, Yarnresolutions, ornpm dedupe.Bundlers can also be told to collapse duplicates — Vite
resolve.dedupe,
webpackresolve.aliaspointing each package at one path:js// vite.config.js export default { resolve: {dedupe: ['lexical', '@lexical/react', '@lexical/utils']}, };Treat that as a safety net, not a fix: it collapses copies that are already
version-compatible, and cannot rescue two genuinely different versions.Duplication does not always come from the dependency tree: importing Lexical
through bothimportandrequirepulls in the ESM and the CJS build of the
same version, which are still two copies. The
React FAQ
covers how this one shows up in@lexical/react.
Packages Lexical Website Docs Concepts Read Only (`packages/lexical-website/docs/concepts/read-only.md`)
Read Mode / Edit Mode
Lexical supports two modes:
- Read mode
- Edit mode
The default behavior for Lexical is edit mode, or more accurately not read only mode. Under-the-hood, the main
implementation detail is that the contentEditable is being set to "false" or "true" depending on the mode.
Specific plugins can listen to the mode change too – allowing them to customize parts of the UI depending on the
mode.
Setting the mode
In order to set the mode, this can be done on creation of the editor, with theeditable property of your app's extension:
import {buildEditorFromExtensions} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {defineExtension} from 'lexical';
const editor = buildEditorFromExtensions(
defineExtension({
dependencies: [RichTextExtension],
editable: false,
name: '@my-app/editor',
}),
);With React, pass the same extension to <LexicalExtensionComposer>:
<LexicalExtensionComposer extension={appExtension}>
...
</LexicalExtensionComposer>After an editor is created, the mode can be changed imperatively:
editor.setEditable(true);Reading the mode
In order to find the current mode of the editor you can use:
const isEditable = editor.isEditable(); // Returns true or falseYou can also get notified when the editor's read only mode has changed:
const removeEditableListener = editor.registerEditableListener(
(isEditable) => {
// The editor's mode is passed in!
console.log(isEditable);
},
);
// Do not forget to unregister the listener when no longer needed!
removeEditableListener();In an extension, depend onWatchEditableExtension,
whose output is a signal holding the current editable state, instead of
registering the listener yourself.
Packages Lexical Website Docs Concepts Selection (`packages/lexical-website/docs/concepts/selection.md`)
Selection
Types of selection
Lexical's selection is part of the EditorState. This means that for every update, or change to the editor, the
selection always remains consistent with that of the EditorState's node tree.
In Lexical, there are four types of selection possible:
RangeSelectionNodeSelectionTableSelection(implemented in@lexical/table)null
It is possible, but not generally recommended, to implement your own selection types that implement BaseSelection.
`RangeSelection`
This is the most common type of selection, and is a normalization of the browser's DOM Selection and Range APIs.RangeSelection consists of three main properties:
anchorrepresenting aRangeSelectionpointfocusrepresenting aRangeSelectionpointformatnumeric bitwise flag, representing any active text formats
Both the anchor and focus points refer to an object that represents a specific part of the editor. The main properties of a RangeSelection point are:
keyrepresenting theNodeKeyof the selected Lexical nodeoffsetrepresenting the position from within its selected Lexical node. For thetexttype this is the character, and for theelementtype this is the child index from within theElementNodetyperepresenting eitherelementortext.
`NodeSelection`
NodeSelection represents a selection of multiple arbitrary nodes. For example, three images selected at the same time.
getNodes()returns an array containing the selected LexicalNodes
`TableSelection`
TableSelection represents a grid-like selection like tables. It stores the key of the parent node where the selection takes place and the start and end points.TableSelection consists of three main properties:
tableKeyrepresenting the parent node key where the selection takes placeanchorrepresenting aTableSelectionpointfocusrepresenting aTableSelectionpoint
For example, a table where you select row = 1 col = 1 to row 2 col = 2 could be stored as follows:
tableKey = 2table keyanchor = 4table cell (key may vary)focus = 10table cell (key may vary)
Note that anchor and focus points work the same way as RangeSelection.
`null`
This is for when the editor doesn't have any active selection. This is common for when the editor has been blurred or when selection
has moved to another editor on the page. This can also happen when trying to select non-editable components within the editor space.
Working with selection
Selection can be found using the $getSelection() helper, exported from the lexical package. This function can be used within
an update, a read, or a command listener.
:::tip
To walk the document from a selection point — or to write a traversal that
handles empty nodes and collapsed selections correctly — see
Node Traversals with NodeCaret.$caretRangeFromSelection(selection) converts a RangeSelection into the
caret range that traversal API works with, and$setSelectionFromCaretRange(range) converts back.
:::
import {$getSelection, SELECTION_CHANGE_COMMAND} from 'lexical';
editor.update(() => {
const selection = $getSelection();
});
editorState.read(() => {
const selection = $getSelection();
});
// SELECTION_CHANGE_COMMAND runs before reconciliation in the pending update.
editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
const selection = $getSelection();
});In some cases you might want to create a new type of selection and set the editor selection to
be that. This can only be done in update or command listeners.
import {$setSelection, $createRangeSelection, $createNodeSelection} from 'lexical';
editor.update(() => {
// Set a range selection
const rangeSelection = $createRangeSelection();
$setSelection(rangeSelection);
// You can also indirectly create a range selection, by calling some of the selection
// methods on Lexical nodes.
const someNode = $getNodeByKey(someKey);
// On element nodes, this will create a RangeSelection with type "element",
// referencing an offset relating to the child within the element.
// On text nodes, this will create a RangeSelection with type "text",
// referencing the text character offset.
someNode.select();
someNode.selectPrevious();
someNode.selectNext();
// You can use this on any node.
someNode.selectStart();
someNode.selectEnd();
// Set a node selection
const nodeSelection = $createNodeSelection();
// Add a node key to the selection.
nodeSelection.add(someKey);
$setSelection(nodeSelection);
// You can also clear selection by setting it to `null`.
$setSelection(null);
});Focus
You may notice that when you issue an editor.update oreditor.dispatchCommand then the editor can "steal focus" if there is
a selection and the editor is editable. This is because the Lexical
selection is reconciled to the DOM selection during reconciliation,
and the browser's focus follows its DOM selection.
If you want to make updates or dispatch commands to the editor without
changing the selection, can use the SKIP_DOM_SELECTION_TAG update tag
(added in v0.22.0):
// Call this from an editor.update or command listener
$addUpdateTag(SKIP_DOM_SELECTION_TAG);If you want to add this tag during processing of a dispatchCommand,
you can wrap it in an editor.update:
// NOTE: If you are already in a command listener or editor.update,
// do *not* nest a second editor.update! Nested updates have
// confusing semantics (dispatchCommand will re-use the
// current update without nesting)
editor.update(() => {
$addUpdateTag(SKIP_DOM_SELECTION_TAG);
editor.dispatchCommand(/* … */);
});SKIP_DOM_SELECTION_TAG does not apply to the initial editor state setup
(the $initialEditorState property of the root extension, orinitialConfig.editorState with the legacy LexicalComposer). On first mount the editor still scrolls
to and focuses the initial selection. To prevent that, call$setSelection(null) inside your initial state setup function:
const editor = buildEditorFromExtensions({
name: '[root]',
// ...
$initialEditorState: () => {
// ... build your initial nodes ...
$setSelection(null);
},
});If you have to support older versions of Lexical, you can mark the editor
as not editable during the update or dispatch.
// NOTE: This code should be *outside* of your update or command listener, e.g.
// directly in the DOM event listener
const prevEditable = editor.isEditable();
editor.setEditable(false);
editor.update(
() => {
// run your update code or editor.dispatchCommand in here
}, {
onUpdate: () => {
editor.setEditable(prevEditable);
},
},
);Reading selection across a shadow boundary
If your plugin reads the DOM selection directly — throughSelection.anchorNode, Selection.getRangeAt(0), or similar — and the
editor's contentEditable lives inside a ShadowRoot, the browser retargets
those reads to the shadow host. See
Shadow DOM and iframes for the shadow-aware helpers
(getDOMSelectionPoints, getDOMSelectionRange, etc.) that return the
un-retargeted boundary points; they fall through to the standard reads in the
plain light DOM, so there's nothing to do until you actually mount the editor
in a shadow tree.
Selection change timing
SELECTION_CHANGE_COMMAND runs before reconciliation, in a writable update,
for native, programmatic, and non-range selection changes. $getSelection()
returns the pending selection; $getPreviousSelection() returns the selection
from the last committed state. A listener can normalize the selection or edit
nodes before that same update commits. Listeners that change the selection may
cause another notification before commit; they must converge.
This covers programmatic changes such as node.select(), which notify without
waiting for a DOM selectionchange event, and non-range selections such asNodeSelection and TableSelection, as well as native range selections.
Selection listeners use the update's normal error handling. A throwing listener
reports through onError and can abort the entire pending update, including the
edit that triggered the notification and any other edits batched into that
commit, just like an explicit command dispatch inside that update. Because a
programmatic selection change also notifies, a listener error can abort content
edits made in the same update. Listener edits do not have a separate rollback
scope.
The DOM is not guaranteed to match the pending state, even for a NodeSelection.
New nodes may not have an element yet, and DOM ranges, layout, and focus may
still reflect the previous state. Schedule DOM-dependent work after the update
and read the reconciled state there:
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
$onUpdate(() => {
editor.read('latest', () => {
// Read $getSelection(), look up elements, or position floating UI here.
});
});
return false;
},
COMMAND_PRIORITY_LOW,
);Import $onUpdate and COMMAND_PRIORITY_LOW from lexical. Read selection and
nodes inside the callback rather than capturing mutable pending objects.registerUpdateListener is another option for UI that must respond to every
commit, including content changes that do not change the selection. Updates
using SKIP_DOM_SELECTION_TAG still deliberately leave the browser selection
unsynchronized.
Update tags and local edits
The notification belongs to the pending update and inherits its tags. A listener
can inspect them with $hasUpdateTag. In particular, Yjs does not sync content
edits back to peers when the update is tagged COLLABORATION_TAG orHISTORIC_TAG; history also treats HISTORIC_TAG as replaying an existing entry.
This applies to content edits made by selection listeners in those updates.
Listeners that only apply to local editing should skip those tagged updates. If
a remote change or undo must trigger an independent local content edit, schedule
it after the tagged commit with $onUpdate, then start a fresh editor.update:
const $applyLocalEdit = () => {
// Read the current selection and nodes, then apply a convergent local edit.
};
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
if ($hasUpdateTag(COLLABORATION_TAG) || $hasUpdateTag(HISTORIC_TAG)) {
$onUpdate(() => editor.update($applyLocalEdit));
} else {
$applyLocalEdit();
}
return false;
},
COMMAND_PRIORITY_LOW,
);Import the tags and $hasUpdateTag from lexical. Re-read the state in the fresh
update; it has its own collaboration and undo behavior. Calling editor.update
directly inside the command listener queues a nested update that still belongs
to the same tagged commit.
Automatic range and cleared-selection notifications require a connected editor
root. Changes committed while those notifications are skipped advance the
selection baseline and are not replayed when the root reconnects. Non-range
selections continue to notify in unmounted and headless editors,
with the same pre-reconciliation timing. Native dirty-selection notifications
may fire even when the selection compares equal to the previous selection.
Packages Lexical Website Docs Concepts Shadow Dom (`packages/lexical-website/docs/concepts/shadow-dom.md`)
sidebar_label: Shadow DOM and iframes
Shadow DOM and iframes
A Lexical editor works when its contentEditable lives inside an open
DOM ShadowRoot (for
example inside a web component)
or inside an <iframe> document. Both are supported out of the box — you only
have to point the editor at the right root element.
Embedding in a shadow root
Attach an open shadow root, render a contentEditable inside it, and pass that
element to editor.setRootElement (or, with @lexical/react, portal theContentEditable into the shadow root — React context flows across the portal,
so the editor is built exactly as it would be in the light DOM):
const host = document.querySelector('#editor-host');
const shadow = host.attachShadow({mode: 'open'});
const contentEditable = document.createElement('div');
contentEditable.contentEditable = 'true';
shadow.appendChild(contentEditable);
editor.setRootElement(contentEditable);Lexical detects the enclosing shadow root from the root element and resolves
selection through it. There is nothing else to configure.
Why it needs platform support
Inside a shadow tree the browser retargets the document's selection to the
shadow host: Selection.anchorNode/focusNode and Selection.getRangeAt
report the host element, not the node the caret is actually in, anddocument.activeElement reports the host rather than the focused element.
Lexical works around this using only standard platform APIs:
- Selection is read with
Selection.getComposedRanges
andSelection.direction,
which return the real (un‑retargeted) boundary points. - Focus is read with
ShadowRoot.activeElement
viaNode.getRootNode. - Caret movement and word/line deletion use the native
Selection.modify, and
selection is written withSelection.setBaseAndExtent, both of which operate
on shadow‑tree nodes directly. - Drop targets and image-drag hit‑tests resolve through
Document.caretPositionFromPoint
with theshadowRootsoption so the dropped point lands on the real node
rather than the retargeted host.
Browser support
The shadow-aware path requires the platform APIs listed above. Lexical falls
back to the light‑DOM reads when those APIs are missing, so editors that don't
live in a shadow tree keep working on any engine the rest of Lexical supports.
| Platform API | Used for | Chrome / Edge | Firefox | Safari |
|---|---|---|---|---|
Selection.getComposedRanges |
Reading the un‑retargeted boundary points | 137+ | 142+ | 17.0+ |
Selection.direction |
Mapping the composed range back onto anchor/focus | 137+ | 126+ | 17.0+ |
ShadowRoot.activeElement |
Resolving the focused element through the host | All modern | All modern | All modern |
Document.caretPositionFromPoint({shadowRoots}) |
Shadow-aware drop / drag hit-tests | 128+ | 125+ | 18.1+ |
Lexical also supports the legacy variadic form of getComposedRanges shipped by
Safari 17–18.1, automatically choosing the dictionary or variadic call shape
at runtime.
Closed shadow roots
A closed shadow root ({mode: 'closed'}) hides its contents from outside code:Node.getRootNode({composed: false}) returns the host on every external probe,
and Selection.getComposedRanges({shadowRoots}) ignores the closed root. The
editor inside a closed shadow root cannot read its own selection through the
host, so closed shadow roots are not supported.
If you need style/markup encapsulation without selection isolation, mount the
editor in an open shadow root with delegatesFocus: true instead — outside
code can still observe focus and selection while CSS and markup remain
encapsulated.
Styling
Shadow trees do not inherit the document's stylesheets, so your editor/theme
CSS has to live inside the shadow root. A <style> element appended to the
root is the simplest option; a
constructable stylesheet
adopted via shadowRoot.adoptedStyleSheets scales better when one stylesheet
serves several editor instances. The playground's ShadowDomWrapper clones
the page's existing <link> / <style> nodes into the shadow root so it can
reuse Vite's HMR-managed stylesheets. This is a property of Shadow DOM, not
of Lexical.
CSS custom properties and inherited HTML attributes (dir, lang) cross
the shadow boundary on their own, so a page-level rule like :root { --editor-bg: #1c1d22; } or a host attribute like <my-editor dir="rtl" lang="ko"> propagates into the editor without further setup. User
preference media queries (@media (prefers-color-scheme: dark),(prefers-reduced-motion: reduce), (forced-colors: active)) evaluate
inside the shadow root just like outside; place them in the shadow-mounted
stylesheet rather than the page sheet.
Embedding in an iframe
An editor whose root element belongs to an <iframe> document is also
supported. Lexical reads the editor's window/document from the root
element (rootElement.ownerDocument.defaultView), so selection and focus are
resolved against the iframe rather than the top‑level document:
const iframeDoc = iframe.contentDocument;
const contentEditable = iframeDoc.querySelector('#editor');
// The editor can be built in the parent frame; once its root element is set it uses
// the iframe's own window and document for selection and focus.
editor.setRootElement(contentEditable);Selection inside an iframe is not retargeted (an iframe is a separate
document, not a shadow boundary), so getComposedRanges is not involved here —
the iframe's own selection already reports the correct nodes.
Shadow-aware helpers
If a plugin reads the DOM selection or the focused element directly, use the
shadow/iframe‑aware helpers exported from lexical instead ofSelection.anchorNode / document.activeElement, so it keeps working in these
contexts. In the plain light DOM each helper degrades to the standard read
without any extra cost.
| Instead of | Use |
|---|---|
Selection.anchorNode / anchorOffset / focusNode / focusOffset |
getDOMSelectionPoints(selection, rootElement) |
selection.getRangeAt(0) |
getDOMSelectionRange(selection, rootElement) |
| Both shapes from one read | getDOMSelectionRangeAndPoints(selection, rootElement) |
selection.getComposedRanges raw call |
getComposedStaticRange(selection, rootElement) |
document.activeElement === el |
getActiveElement(el) === el |
| Deepest focused element through nested shadow trees | getActiveElementDeep(document) |
event.target on a composed: true event listening above the shadow boundary |
getComposedEventTarget(event) |
node.getRootNode() instanceof ShadowRoot (cross‑realm‑safe) |
isDOMShadowRoot(node) |
| Walking up to discover enclosing shadow roots | getDOMShadowRoots(node) |
All nine helpers are marked @experimental while the surface stabilizes — the
return shape may change, but the behavior in the light DOM is stable.
`getDOMSelectionPoints` read semantics
In the light DOM getDOMSelectionPoints returns the live Selection itself,
so each property read is deferred and $updateDOMSelection only pays for the
synchronous style/layout recalculation that a Selection.anchorNode /focusNode read triggers when it actually needs the value. Inside a shadow
tree the return is a snapshot taken at call time, includingSelection.direction so callers can branch on it explicitly. Read the four
points immediately after the call rather than caching the returned reference,
or use points === domSelection to detect the alias path.
If a future engine ships getComposedRanges without Selection.direction
(no current configuration matches — every engine that ships the former
also ships the latter), the snapshot's direction is undefined and
anchor/focus default to the StaticRange's tree order; callers needing strict
backward fidelity should check direction !== undefined before relying on
the swap.
Form association
A Lexical editor inside a form-associated custom
element
participates in a <form> like any built-in form control: submission carries
the editor's value, form reset clears it, and validation flows through. The
sketch:
/* Detailed source-code truncated for AI context efficiency. */Without the dirty-leaf/element gate every caret move would wake the form'soninput listeners, so the input event matches HTMLInputElement's
contract only after that guard. The initial setFormValue is what keeps a
form submission before the user types from carrying an empty value, since
the update listener has not fired yet at that point. A full reference
implementation lives inexamples/shadow-dom-web-component.
DOM moves (re-parenting the host into a different <form> or list) triggerdisconnectedCallback followed by connectedCallback, which rebuilds the
editor against a fresh contentEditable. Round-trip the user's content the
same way <input> and <textarea> round-trip their value attribute:
cache the serialized state in disconnectedCallback and replay it throughparseEditorState on the next mount. The reference dev example uses apendingState field for exactly this.
The same form-associated host can also implement the rest of the
form-associated callbacks:formStateRestoreCallback(state, reason) re-hydrates the editor on bfcache
navigation (reason: 'restore') or form autocomplete restore
(reason: 'autocomplete'); formAssociatedCallback(form) reacts to
programmatic form moves; formResetCallback() clears the editor when the
surrounding form is reset; and formDisabledCallback(isDisabled) flips the
editor's editable state when an ancestor <fieldset disabled> toggles. The
serialized JSON internals.setFormValue published earlier is what comes
back in the state-restore callback.
Common pitfalls
Moving an existing editor into a shadow root exposes a handful of recurring
mismatches between the DOM APIs you'd reach for and what they report.
Event retargeting
Listeners above the shadow boundary — window, document'sselectionchange, popups portaled to document.body — see event.target
retargeted to the shadow host, not the actual clicked element. ArootElement.contains(event.target) gate therefore always rejects clicks
that came from the editor. Resolve through the composed path:
const target = getComposedEventTarget(event);
if (target instanceof Node && rootElement.contains(target)) {
// click really did land inside the shadow-hosted editor
}Focus probes
document.activeElement reports the host when focus is inside an open
shadow tree, so document.activeElement === rootElement is always false.getActiveElement(rootElement) reads DocumentOrShadowRoot.activeElement
through Node.getRootNode instead, and getActiveElementDeep keeps
descending into nested shadow roots when an editor whose decorator embeds a
web component needs the innermost focused element.
if (getActiveElement(rootElement) === rootElement) { ... }Outside-click handlers
A typical dropdown registers document.addEventListener('click', ...) and
calls setShowDropDown(false) whenever button.contains(event.target) is
false. From inside a shadow tree that check always fails (the target is the
host), so the dropdown closes on the very click that opened it. Compare
against getComposedEventTarget(event) instead — the same fix the playground'sDropDown and @lexical/react's DraggableBlockPlugin use.
Drop hit-tests
document.caretRangeFromPoint and the no-argumentdocument.caretPositionFromPoint return the host when the pointer is over
shadow content, so an image drop lands on the host rather than the textnode
under the cursor. @lexical/clipboard's internal caretFromPoint helper switches tocaretPositionFromPoint(x, y, {shadowRoots}) when rootElement lives in a
shadow tree, and verifies the returned offset node really did land inside
one of the requested shadow roots — engines that silently ignore the option
fall through to the legacy paths.
Style mirror cleanup
If your shadow mount mirrors <style> / <link> nodes from document.head
and watches for additions via MutationObserver, mirror removals too;
otherwise stylesheets removed by HMR or a runtime theme swap linger inside
the shadow. ShadowDomWrapper tracks an original → clone map so an
upstream removal drops the corresponding clone, and on unmount it removes
every clone so React 18 StrictMode's double-mount can't accumulate
duplicates through the persistent shadow root.
Dev-mode HMR has two paths that a childList-only observer misses.
Firefox replaces a <style>'s text content in place, so the observer
needs characterData + subtree to see the mutation; Chrome and WebKit
update CSS through the CSSOM (style.sheet.replaceSync / insertRule),
which the DOM observer never sees at all. ShadowDomWrapper also
subscribes to Vite's vite:afterUpdate hook for an explicit resync of
every clone after each HMR pass — import.meta.hot is undefined in
production builds, so the hook is a no-op outside dev.
Popover and dialog layout
The UA stylesheet defaults a closed
Popover (or a<dialog> without open) to display: none. A base rule like#my-popover { display: flex; ... } overrides that and leaves the popover
visible after hidePopover(). Scope layout to the open state instead —#my-popover:popover-open { display: flex; } — so the closed state honors
the UA default. This is a popover-API gotcha, not a shadow-specific one,
but it surfaces here because floating UI anchored to a shadow-root
selection (a format popover or link editor that reads coordinates throughgetDOMSelectionRangeAndPoints) is a common shadow integration pattern.
Observers across the shadow boundary
ResizeObserver, IntersectionObserver, and MutationObserver observe
nodes inside an open shadow root with no special configuration — pass the
inner node (the contentEditable or the host) just like any other DOM
target. A MutationObserver registered on the host does not see mutations
inside the shadow tree, though; observe the contentEditable or a
shadow-internal container instead. The host's attributeChangedCallback
covers the host's own attribute changes.
Migrating an existing light-DOM editor
The same code shape keeps working in a shadow root; migration is mostly
about removing pre-shadow workarounds. Direct selection reads
(Selection.anchorNode, Selection.getRangeAt(0),document.querySelector('[contenteditable]') used as an editor probe) route
through getDOMSelectionPoints / getDOMSelectionRange /getActiveElement, and any event.target read above the shadow boundary
becomes getComposedEventTarget(event). CSS variables inherit through the
shadow boundary but class declarations don't, so the editor/theme CSS has
to be adopted or cloned into the shadow root.
The focus and popup paths are the ones that surprise people in review:
outside-click handlers, blur-then-refocus, and "did the editor lose focus?"
probes usually need getActiveElement(rootElement) rather thandocument.activeElement; floating UI that portals into document.body and
resolves hover with element.getRootNode().elementFromPoint(x, y) keeps the
same call shape but has to guard the popup root narrowing against a
detached popup whose getRootNode() returns itself.
Examples
Runnable examples live in the repository:
examples/shadow-dom
— a React editor inside a shadow root with a light‑DOM toolbar.examples/shadow-dom-web-component
— a framework‑free<lexical-editor>custom element, form‑associated viaElementInternals. The page also mounts one instance inside a wrapper<div>that opens its own shadow root, exercising the multi‑level
walk through two nested shadow boundaries.examples/vanilla-js-iframe
— an editor rendered into an<iframe>.- The playground's Render in Shadow DOM setting toggles the same editor
between light and shadow mounts, useful for spot-checking your plugins
against both modes.
Packages Lexical Website Docs Concepts Transforms (`packages/lexical-website/docs/concepts/transforms.md`)
Node Transforms
Transforms are the most efficient mechanism to respond to changes to the EditorState.
For example:
User types a character and you want to color the word blue if the word is now equal to "congrats".
We programmatically add an @Mention to the editor, the @Mention is immediately next to another @Mention (@Mention@Mention). Since we believe this makes mentions hard to read, we want to destroy/replace both mentions and render them as plain TextNode's instead.
const removeTransform = editor.registerNodeTransform(TextNode, (textNode) => {
if (textNode.getTextContent() === 'blue') {
textNode.setTextContent('green');
}
});Syntax
editor.registerNodeTransform<T extends LexicalNode>(klass: Klass<T>, listener: Transform<T>): () => voidLifecycle
Transforms are executed sequentially before changes are propagated to the DOM and multiple transforms still lead to a single DOM reconciliation (the most expensive operation in Lexical's lifecycle).
flowchart LR
accTitle: Transforms lifecycle
accDescr: A keyboard event leads to an update function. Its changes mark nodes dirty, and the transforms registered for those nodes run one after another. If a transform changed more nodes, the transforms for those nodes run again. When nothing is left dirty, the DOM is reconciled once and the update listeners run.
event{{"Keyboard event"}} --> update["Update function"]
update --> t1["transform1"]
t1 --> t2["transform2"]
t2 --> dirty{"Nodes still dirty?"}
dirty -->|"yes"| t1
dirty -->|"no"| reconcile["DOM reconciled once"]
reconcile --> listeners["Update listeners"]:::caution Beware!
While it is possible to achieve the same or very similar result through an update listener followed by an update, this is highly discouraged as it triggers an additional render (the most expensive lifecycle operation).
Additionally, each cycle creates a brand new EditorState object which can interfere with features like undo/redo (HistoryExtension) if not handled correctly.
editor.registerUpdateListener(() => {
editor.update(() => {
// Don't do this
});
});:::
Dirty Nodes
Dirty Leaves (any LexicalNode that is not an ElementNode) and Dirty Elements
are tracked separately to support the transform heuristic.
Internally, there are two states for dirty nodes:
- Intentionally Dirty nodes (leaves or elements) had
node.getWritable()ornode.markDirty()(an alias) called on them. Maintaining the
tree-of-doubly-linked-lists structure of a lexical document requires that
this Intentionally Dirty state will propagate to immediate siblings and in
some cases the parent node. - Unintentionally Dirty Element nodes are an ancestor of a
dirty node that were not explicitly marked as Intentionally Dirty
Only elements can be Unintentionally Dirty, because
leaves by definition can not have children.
The reconciler works by starting at the RootNode (which is the ancestor
of any attached node, and thus always dirty whenever any attached node
is dirty). Intentionally Dirty nodes have their createDOM and/or updateDOM
called. Dirty Elements reconcile all of their children. Thus, reconciliation
stops at the highest node in a subtree that has no dirty nodes (unless it
is running with a flag to do a full reconciliation which considers all
nodes as Intentionally Dirty).
:::info
The transform heuristic depends on these internal implementation details to
find a fixed point where no more transforms are required.
:::
Transform heuristic
- We transform leaves first. If transforms generate additional dirty nodes we repeat
step 1. The reasoning behind this is that marking a leaf as dirty marks all its parent elements as dirty too. - We transform elements.
- If element transforms generate additional dirty nodes we repeat
step 1. - If element transforms only generate additional dirty elements we only repeat
step 2.
- If element transforms generate additional dirty nodes we repeat
A node will be marked as dirty on any (or most) modifications done to it, its children, or siblings in certain cases.
Preconditions
Preconditions are fundamental for transforms to prevent them from running multiple times and ultimately causing an infinite loop.
Transforms are designed to run when nodes have been modified (aka Intentionally Dirty). For the most part, transforms only need to run once after the update but the sequential nature of transforms makes it possible to have order bias. Hence, transforms are run over and over until this particular type of Node is no longer marked as intentionally dirty by any of the transforms.
Hence, we have to make sure that the transforms do not mark the node dirty unnecessarily.
// When a TextNode changes (marked as dirty) make it bold
editor.registerNodeTransform(TextNode, textNode => {
// Important: Check current format state
if (!textNode.hasFormat('bold')) {
textNode.toggleFormat('bold');
}
});But oftentimes, the order is not important. The below would always end up in the result of the two transforms:
// Extension 1
editor.registerNodeTransform(TextNode, textNode => {
// This transform runs twice but does nothing the first time because it doesn't meet the preconditions
if (textNode.getTextContent() === 'modified') {
textNode.setTextContent('re-modified');
}
})
// Extension 2
editor.registerNodeTransform(TextNode, textNode => {
// This transform runs only once
if (textNode.getTextContent() === 'original') {
textNode.setTextContent('modified');
}
})
// App
editor.registerUpdateListener(({editorState}) => {
const text = editorState.read(() => $getRoot().getTextContent());
// text === 're-modified'
});:::info
The transform heuristic considers RootNode to be Intentionally Dirty whenever
any node is dirty, and it ensures that this transform is applied last after all
other transforms. In this way, you can consider it a "pre-update" listener,
which occurs before any DOM reconciliation has happened. As with any other
transform, it may get called multiple times in a given update (especially if
your RootNode transform makes any node dirty).
:::
Transforms on parent nodes
Transforms are very specific to a type of node. This applies to both the declaration (registerNodeTransform(ImageNode)) and the times it triggers during an update cycle.
// Won't trigger
editor.registerNodeTransform(ParagraphNode, () => {});
// Will trigger as TextNode was marked dirty
editor.registerNodeTransform(TextNode, () => {});
editor.update(() => {
const textNode = $getNodeByKey('3');
textNode.setTextContent('foo');
});While the marked dirty rule is always true, there are some cases when it's not immediately obvious and/or we force nearby nodes to become dirty for the sake of easier transform logic:
You add a node to an ElementNode, the ElementNode and the newly added children are marked dirty, also its new immediate siblings
You remove a node, its parent is marked dirty, also the node's immediate siblings prior to being removed
You move a node via replace, rules 2 and 1 are applied.
editor.registerNodeTransform(ParagraphNode, paragraph => {
// Triggers
});
editor.update(() => {
const paragraph = $getRoot().getFirstChild();
paragraph.append($createTextNode('foo'));
});registerLexicalTextEntity
It is common to have certain nodes that are created/destroyed based on their text content and siblings. For example, #lexical is a valid hashtag whereas #!lexical is not.
This is a perfectly valid case for transforms but we have gone ahead and already built a utility transform wrapper for you for this specific case:
registerLexicalTextEntity<T extends TextNode>(
editor: LexicalEditor,
getMatch: (text: string) => null | EntityMatch,
targetNode: Klass<T>,
createNode: (textNode: TextNode) => T,
): Array<() => void>;Examples
Packages Lexical Website Docs Concepts Traversals (`packages/lexical-website/docs/concepts/traversals.md`)
Node Traversals with NodeCaret
NodeCaret offers a unified and efficient way for traversing the document
tree, making it much easier to correctly implement traversals and avoid
edge cases around empty nodes and collapsed selections.
These new low-level functions were all designed to work together as a
fully featured relatively lightweight API to use in the core to
allow us to gradually address some edge cases and then simplify and shrink
the code. We expect higher-level utilities to be developed and shipped
in @lexical/utils or another module at a later date. The current overhead
should be less than 3kB in a production environment.
The NodeCaret API was introduced in lexical v0.25.0.
Concepts
The core concept with NodeCaret is that you can represent any specific
point in the document by using an origin node, a direction that
points towards an adjacent node (next or previous), and a type
to specify whether the arrow points towards a sibling (sibling) or
towards a child (child).
All of these types have a D type parameter that must be a CaretDirection, so you
can not accidentally mix up next and previous carets. Many of them
also have a T type parameter that encodes the type of the origin node.
:::tip
The methods of a caret are designed to operate on nodes attached to the origin
in the designated direction, not the origin itself. For example, this code is
a no-op because it will attach a node to the origin, and then remove the node
that was just attached.
// The origin is unaffected (other than being marked dirty)
caret.insert($createTextNode('no-op')).remove();:::
:::warning
Carets are immutable, and designed for low-level usage. There is no attempt
for carets to automatically update based on changes to the document
(this is a common source of bugs when working with RangeSelection).
Functions and methods that work with carets and are expected to change the
structure of the document will always return a possibly new caret.
The origin of a caret is the exact version of the object that it was
constructed with, all accessor methods on that origin will generally callorigin.getLatest() so the operations will see the latest version.
:::
NodeCaret
NodeCaret is any SiblingCaret or any ChildCaret
- Typically constructed with
$getChildCaretOrSelf($getSiblingCaret(origin, direction))
which returns aChildCaretwhen the origin is anElementNode
:::tip
This type does not include TextPointCaret or TextPointCaretSlice,
so you will not have to consider those edge cases when you see this
more specific type.
:::
SiblingCaret
SiblingCaret is a caret that points towards a sibling of the origin
- Constructed with
$getSiblingCaret(origin: LexicalNode, direction: CaretDirection) - The
nextdirection points towards the right - The
previousdirection points towards the left
→ direction: 'next' |
← direction: 'previous' |
|
|---|---|---|
getParentAtCaret() |
origin.getParent() |
origin.getParent() |
getNodeAtCaret() |
origin.getNextSibling() |
origin.getPreviousSibling() |
insert(node) |
origin.insertAfter(node) |
origin.insertBefore(node) |
ChildCaret
ChildCaret is a caret that points towards the first or last child of the origin
- Constructed with
$getChildCaret(origin: ElementNode, direction: CaretDirection) - The
nextdirection points towards the first child - The
previousdirection points towards the last child
↘ direction: 'next' |
↙ direction: 'previous' |
|
|---|---|---|
getParentAtCaret() |
origin |
origin |
getNodeAtCaret() |
origin.getFirstChild() |
origin.getLastChild() |
insert(node) |
origin.splice(0, 0, node) |
origin.append(node) |
PointCaret
PointCaret is any TextPointCaret, SiblingCaret or ChildCaret. This
type can be used to represent any point in the document that PointType can represent.
TextPointCaret
TextPointCaret is basically a SiblingCaret with a TextNode origin and an offset property
- Constructed with
$getTextPointCaret(origin, direction, offset) - The
offsetproperty is an absolute index into the string - The
nextdirection implies all text content afteroffset - The
previousdirection implies all text content beforeoffset - All methods that are also present on
SiblingCaretbehave in the same way
TextPointCaretSlice
TextPointCaretSlice is a wrapper for TextPointCaret that provides a signed distance.
- Constructed with
$getTextPointCaretSlice(caret, distance) $splitTextPointCaretSlice(slice, selection?)isolates the slice in a text node,
splitting at its boundaries when needed. It returnsnullfor an empty slice
and the original node for a whole-node slice. Pass the range selection that
produced the slice to preserve its endpoints, including element points and
detached selections. LikeTextNode.splitText, it does not treat token or
segmented nodes as atomic.- There are convenience methods like
removeTextSlice()andgetTextContent(),
so it's not generally necessary to know the implementation details here Math.min(caret.offset, caret.offset + distance)refers to the start offset of the sliceMath.max(caret.offset, caret.offset + distance)refers to the end offset of the slice- The
directionof the caret is generally ignored when working with aTextPointCaretSlice, the slice is in absolute string coordinates
:::info
The property name distance was chosen because length and size are
commonly used on other data structures in JavaScript and Lexical, and they
are overwhelmingly non-negative. While most uses of distance are also
non-negative, in some contexts such as computer graphics it is not uncommon
to use
Signed distance functions
where the distance metric is signed.
In SDF terms, the subset of the space is [offset, ∞). Any coordinate less
than the offset boundary is a negative distance; otherwise the distance is
non-negative.
:::
CaretRange
CaretRange contains a pair of PointCaret that are in the same direction. It
is equivalent in purpose to a RangeSelection, and is what you would generally
use for depth first traversals.
- Constructed with
$getCaretRange(anchor, focus),$caretRangeFromSelection(selection),
or$extendCaretToRange(anchor) - The
anchoris the start of the range, generally where the selection originated,
and it is "anchored" in place because when a selection grows or shrinks only thefocuswill be moved - The
focusis the end of the range, where the blinking cursor is, it's the current
focus of the user - Anchor and focus must point in the same direction. The
anchorpoints towards the first
node in the range and the focus points towards the first node not in the range - The
getTextSlices()method is essential to handle the literal edge cases where
the anchor and/or focus are aTextPointCaret. These edges are not included
in the default caret iteration of theCaretRange.
:::warning
If you are iterating a CaretRange you must consider the getTextSlices()
separately, they are not included in the iteration. This is so you don't have
to consider TextPointCaretSlice at every step. They are literal edge cases
that can only be at the start and/or end and typically have special
treatment (splitting instead of removing, for example).
:::
Traversal Strategies
Adjacent Caret Traversals
The lowest level building block for traversals with NodeCaret is the adjacent caret
traversal, which is supported directly by methods of NodeCaret.
getAdjacentCaret() - Gets a SiblingCaret for the node attached to
origin in direction. If there is no attached node, it will return null
getParentCaret(rootMode) - Gets a SiblingCaret for the parent node
of origin in the same direction. If there is no parent node, or the parent
is a root according to rootMode, then it will return null. rootMode
may be 'root' to only return null for RootNode or 'shadowRoot' to
return null for RootNode or any ElementNode parent where
isShadowRoot() returns true
getChildCaret() - Gets a ChildCaret for this origin, or null if the
origin is not an ElementNode. Will return this if the caret is already
a ChildCaret
For example, iterating all siblings:
// Note that NodeCaret<D> already implements Iterable<NodeCaret<D>> in this
// way, so this function is not very useful. You can just use startCaret as
// the iterable.
function *$iterSiblings<D extends CaretDirection>(
startCaret: NodeCaret<D>
): Iterable<SiblingCaret<LexicalNode, D>> {
// Note that we start at the adjacent caret. The start caret
// points away from the origin node, so we do not want to
// trick ourselves into thinking that that origin is included.
for (
let caret = startCaret.getAdjacentCaret();
caret !== null;
caret = caret.getAdjacentCaret()
) {
yield caret;
}
}Examples
Given the following document tree, here are some examples of using the
adjacent node traversal:
Root
- Paragraph A
- Text A1
- Link A2
- Text A3
- Text A4
- Paragraph B
- Text B1
- Paragraph C
// The root does not have sibling nodes
const carets = [...$getSiblingCaret($getRoot(), 'next')];
expect(carets).toEqual([]);// The adjacent node to a ChildNode is its first or last child
// and is always a SiblingNode. It does not traverse deeper.
const carets = [...$getChildCaret($getRoot(), 'next')];
// next starts at the first child
expect(carets).toEqual([
$getSiblingCaret(paragraphA, 'next'),
$getSiblingCaret(paragraphB, 'next'),
$getSiblingCaret(paragraphC, 'next'),
]);
// previous starts at the last child
const prevCarets = [...$getChildCaret($getRoot(), 'previous')];
expect(prevCarets).toEqual([
$getSiblingCaret(paragraphC, 'previous'),
$getSiblingCaret(paragraphB, 'previous'),
$getSiblingCaret(paragraphA, 'previous'),
]);// The iteration starts at the node where the head of the "arrow"
// is pointing, which is away from the origin (the tail of the "arrow").
const carets = [...$getSiblingCaret(paragraphB, 'next')];
expect(carets).toEqual([
$getSiblingCaret(paragraphC, 'next'),
]);
const prevCarets = [...$getSiblingCaret(paragraphB, 'previous')];
expect(prevCarets).toEqual([
$getSiblingCaret(paragraphA, 'previous'),
]);Depth First Caret Traversals
The strategy to do a depth-first caret traversal is to use an adjacent caret
traversal and immediately use a ChildCaret any time that an ElementNode
origin is encountered. This strategy yields all possible carets, but each
ElementNode in the traversal may be yielded once or twice (a ChildCaret on
enter, and a SiblingCaret on leave). Allowing you to see whether anElementNode is partially included in the range or not is one of the
reasons that this abstraction exists.
function *$iterCaretsDepthFirst<D extends CaretDirection>(
startCaret: NodeCaret<D>
): Iterable<NodeCaret<D>> {
function step(prevCaret: NodeCaret<D>): null | NodeCaret<D> {
// Get the adjacent SiblingCaret
const nextCaret = prevCaret.getAdjacentCaret();
return (
// If there is a sibling, try and get a ChildCaret from it
(nextCaret && nextCaret.getChildCaret()) ||
// Return the sibling if there is one
nextCaret ||
// Return a SiblingCaret of the parent, if there is one
prevCaret.getParentCaret('root')
);
}
// You may add an additional check here, usually some specific
// caret to terminate the iteration with (such as the parent caret
// of startCaret):
//
// `caret !== null || caret.is(endCaret)`
//
for (
let caret = step(startCaret);
caret !== null;
caret = step(caret)
) {
yield caret;
}
}Normally this type of iteration would be done from a CaretRange, where you
would specify a precise end caret (focus).
function $iterCaretsDepthFirst<D extends CaretDirection>(
startCaret: NodeCaret<D>,
endCaret?: NodeCaret<D>,
): Iterable<NodeCaret<D>> {
return $getCaretRange(
startCaret,
// Use the root as the default end caret, but you might choose
// to use startCaret.getParentCaret('root') for example
endCaret || $getSiblingCaret($getRoot(), startCaret.direction)
);
}To get all nodes that are entirely selected between two carets:
function *$iterNodesDepthFirst<D extends CaretDirection>(
startCaret: NodeCaret<D>,
endCaret: NodeCaret<D> = $getChildCaret($getRoot(), startCaret.direction),
): Iterable<LexicalNode> {
const seen = new Set<NodeKey>();
for (const caret of $getCaretRange(startCaret, endCaret)) {
const {origin} = caret;
if ($isChildCaret(caret)) {
seen.add(origin.getKey());
} else if (!$isElementNode(origin) || seen.has(origin.getKey())) {
// If the origin is an element and we have not seen it as a ChildCaret
// then it was not entirely in the CaretRange
yield origin;
}
}
}Examples
Given the following document tree, here are some examples of using the
depth-first node traversal (with a CaretRange):
Root
- Paragraph A
- Text A1
- Link A2
- Text A3
- Text A4
- Paragraph B
- Text B1
- Paragraph C
// A full traversal of the document from root
const carets = [...$getCaretRange(
// Start with the arrow pointing towards the first child of root
$getChildCaret($getRoot(), 'next'),
// End when the arrow points away from root
$getSiblingCaret($getRoot(), 'next'),
)];
expect(carets).toEqual([
$getChildCaret(paragraphA, 'next'), // enter Paragraph A
$getSiblingCaret(textA1, 'next'),
$getChildCaret(linkA2, 'next'), // enter Link A2
$getSiblingCaret(textA3, 'next'),
$getSiblingCaret(linkA2, 'next'), // leave Link A2
$getSiblingCaret(textA4, 'next'),
$getSiblingCaret(paragraphA, 'next'), // leave Paragraph A
$getChildCaret(paragraphB, 'next'), // enter Paragraph B
$getSiblingCaret(textB1, 'next'),
$getSiblingCaret(paragraphB, 'next'), // leave Paragraph B
$getChildCaret(paragraphC, 'next'), // enter Paragraph C
$getSiblingCaret(paragraphC, 'next'), // leave Paragraph C
]);Future Direction
It's expected that higher-level abstractions will be built on top of this
outside of the core, either in @lexical/utils or a separate companion package.
This is just designed to be the lowest-level layer with a consistent and
type-safe interface. That sort of abstraction will probably look a little bit
like cheerio or jQuery, but for working with Lexical documents. It is not
expected that more abstractions will be added to the core.
In order to reduce code size and eliminate bugs, more of the core will be
refactored to use NodeCaret internally.
Once this happens, it's possible that the internal structure of PointType
and/or RangeSelection may change to accommodate NodeCaret, as it is more
resilient to document changes (only changes that directly affect the
origin node will "break" the point). A simple version of this would be to
create a caret any time that the point changes, and use that caret
as a fallback if the selection would otherwise be lost.
It may be the case that NodeCaret will become the lowest level API, working
directly with private LexicalNode/ElementNode internals. When/if that happens,
the methods on LexicalNode will remain for backwards compatibility,
but overriding them will not be supported. It isn't particularly safe to
override them as-is anyway, and these overrides are frequently the
root cause of bugs (e.g. parents that remove themselves after an operation
on a child, causing the point to be lost unless the caller was sophisticated
enough to store the array of parents).
History
Before NodeCaret, Lexical's core API offered a relatively low-level DOM-like
interface for working with nodes and traversing them. It has accumulated
many functions over time for performing various kinds of traversals around
the tree (finding ancestors, children, depth, siblings, etc.), but most of
them are not implemented in a way that makes them easy to combine
efficiently, and many of them have edge cases that are difficult to avoid
and can't really be addressed without breaking compatibility.
Many of these functions also have a lot of edge cases, particularly around
assuming the reference nodes are inclusive. Many are also left-to-right
biased, don't offer an iterative version that can be aborted early or
consumed on the fly, etc.
Refactoring many of these to use something like PointType would almost
be sufficient for many of these use cases, but the representation of
that type is inefficient and error-prone as any mutation to the tree
requires that each point be manually recomputed. PointType is also
directionless, forcing a specific left-to-right bias into most APIs.RangeSelection can be used in many cases because a direction can
be inferred from any two different points, but that collapses with
a single point. It's also impractical to use RangeSelection
concurrently with mutations due to the problems with PointType.
NodeCaret was born out of frustration with these APIs and a desire
to unify it all in a coherent way to simplify and reduce errors in
the core.
Terminology
Caret
The term Caret was chosen because it is concise and specific term
specific to a point in a text document. Lexical is "an extensible text editor
framework" so it makes sense that navigation in the document would use
terms relevant to text. Most other terms such as Cursor or Point
already have meanings in Lexical and/or are less specific.
See also:
Origin
The origin is the reference node for a NodeCaret. Absolute coordinates
are determined by combining this origin node and an "arrow" that points
towards to where the adjacent node is (or could be). The "arrow" is
determined by the direction and type of the caret.
In a way this "arrow" is considered to be something like a unit vector
to indicate the direction, and adding it to an origin allows you to specify
an absolute location relative to that origin. Unlike the other coordinate
systems available in Lexical, it does not need recomputing whenever
siblings or a parent changes, so long as the origin node is still attached.
See also:
ChildCaret / SiblingCaret
These were chosen because they match the existing methods on ElementNode
and LexicalNode (getFirstChild, getNextSibling, etc.)
Direction
'next' and 'previous' were chosen for direction mostly to match the
existing methods such as getNextSibling() that exist in DOM and in Lexical.
Using other words such as 'left' and 'right' would be ambiguous since
text direction can be bidirectional and already uses the terms left-to-right
and right-to-left.
Distance
The property name distance was chosen for TextPointCaretSlice becauselength and size are commonly used on other data structures in JavaScript
and Lexical, and they are overwhelmingly non-negative. While most uses ofdistance are also non-negative, in some contexts such as computer graphics
it is not uncommon to use
Signed distance functions
where the distance metric is signed.
In SDF terms, the subset of the space is [offset, ∞). Any coordinate less
than the offset boundary is a negative distance; otherwise the distance is
non-negative.
Packages Lexical Website Docs Concepts Updates (`packages/lexical-website/docs/concepts/updates.mdx`)
import UpdateLifecycle from '@site/src/components/UpdateLifecycle';
Updates
Every change to the document happens in an update. Updates are synchronous:
the callback you pass to editor.update() runs right away and changes a
pending copy of the editor state. Committing that state and applying it to the
DOM is batched, so several updates in the same tick lead to one DOM change.
See Editor State for how to read and write
the state inside an update.
The update lifecycle {#update-lifecycle}
Lexical uses double-buffering. The current editor state is frozen; an update
works on a pending copy, and updates made in the same tick are batched into
it. When the batch commits, the pending state becomes the new, frozen current
state, and the reconciler changes only the parts of the DOM that belong to
nodes the updates changed, so it skips most of the diffing a virtual DOM would
do. Keeping every committed state frozen is also what makes features such as
undo/redo cheap to implement. This is what happens, in order:
/* Detailed source-code truncated for AI context efficiency. */To see how a real change moves through these phases, step through one of
these examples:
To make an update commit before editor.update() returns, pass{discrete: true}; see
Synchronous reconciliation with discrete updates.
Changes from outside Lexical {#outside-changes}
The editor state, not the DOM, is the source of truth. For some plain typing,
Lexical lets the browser change the DOM itself for performance and then
updates the editor state from the input event. Beyond that, Lexical watches
its root element with a MutationObserver (paused while the reconciler makes
its own changes) and handles any other change that did not come from Lexical.
It keeps a change only if it looks like native text input, and reverts
everything else:
- Kept: text changes inside a text node. When the characters of a DOM
text node that Lexical rendered change, which is what typing, spellcheck,
autocorrect, and IME composition produce, Lexical reads the new text into
the matchingTextNode. Changes that arrive right after a text input event
are left to theinputhandler instead. - Reverted: structural changes. Elements or other DOM nodes added or
removed inside the editor, for example by a browser extension, a script, or
the browser's own editing of block structure, are undone. Added nodes are
removed, removed nodes are put back by their parent node, stray<br>
elements the browser adds are cleaned up, and the previous selection is
restored, so the DOM matches the current editor state again. - Ignored: DOM that Lexical does not manage. The contents of decorator
nodes belong to your framework, and DOM that a node or extension adds on
purpose can be marked withsetDOMUnmanaged()so the observer leaves it
alone.
Update Tags
Update tags are string identifiers that can be attached to an update to indicate its type or purpose. They can be used to control how updates are processed, merged, or handled by listeners. Multiple tags can be used in a single update.
You can add tags in two ways:
- Using the
tagoption ineditor.update():
import {HISTORY_PUSH_TAG, PASTE_TAG} from 'lexical';
editor.update(
() => {
// Your update code
},
{
tag: HISTORY_PUSH_TAG, // Single tag
},
);
editor.update(
() => {
// Your update code
},
{
tag: [HISTORY_PUSH_TAG, PASTE_TAG], // Multiple tags
},
);- Using the
$addUpdateTag()function within an update:
import {HISTORY_PUSH_TAG} from 'lexical';
editor.update(() => {
$addUpdateTag(HISTORY_PUSH_TAG);
// Your update code
});You can check if a tag is present using $hasUpdateTag():
import {HISTORIC_TAG} from 'lexical';
editor.update(() => {
$addUpdateTag(HISTORIC_TAG);
console.log($hasUpdateTag(HISTORIC_TAG)); // true
});Note: While update tags can be checked within the same update using $hasUpdateTag(), they are typically accessed in update and mutation listeners through the tags and updateTags properties in their respective payloads. Here's the more common usage pattern:
import {HISTORIC_TAG} from 'lexical';
editor.registerUpdateListener(({tags}) => {
if (tags.has(HISTORIC_TAG)) {
// Handle updates with historic tag
}
});
editor.registerMutationListener(MyNode, (mutatedNodes, {updateTags}) => {
// updateTags contains tags from the current update
if (updateTags.has(HISTORIC_TAG)) {
// Handle mutations with historic tag
}
});Common Update Tags
Lexical provides several built-in update tags that are exported as constants:
HISTORIC_TAG: Indicates that the update is related to history operations (undo/redo)HISTORY_PUSH_TAG: Forces a new history entry to be createdHISTORY_MERGE_TAG: Merges the current update with the previous history entryPASTE_TAG: Indicates that the update is related to a paste operation.@lexical/historytreats this as a history boundary so the paste gets its own undo entryCUT_TAG: Indicates that the update is related to a cut operation.@lexical/historytreats this as a history boundary so the cut gets its own undo entryCOLLABORATION_TAG: Indicates that the update is related to collaborative editingSKIP_COLLAB_TAG: Indicates that the update should skip collaborative syncSKIP_SCROLL_INTO_VIEW_TAG: Prevents scrolling the selection into viewSKIP_DOM_SELECTION_TAG: Prevents updating the DOM selection (useful for updates that shouldn't affect focus)
Tag Validation
To prevent typos and ensure type safety when using update tags, Lexical exports constants for all built-in tags. It's recommended to always use these constants instead of string literals:
import {HISTORIC_TAG, HISTORY_PUSH_TAG, COLLABORATION_TAG} from 'lexical';
editor.update(() => {
// Using constants ensures type safety and prevents typos
$addUpdateTag(HISTORIC_TAG);
// These constants can be used in update options
editor.update(
() => {
// Your update code
},
{
tag: HISTORY_PUSH_TAG,
},
);
// And in listener checks
editor.registerUpdateListener(({tags}) => {
if (tags.has(COLLABORATION_TAG)) {
// Handle collaborative updates
}
});
});Custom Tags
While Lexical provides common tags as constants, you can also define your own constants for custom tags to maintain consistency and type safety:
// Define your custom tags as constants
const MY_FEATURE_TAG = 'my-custom-feature';
const MY_UPDATE_TAG = 'my-custom-update';
editor.update(
() => {
$addUpdateTag(MY_FEATURE_TAG);
},
{
tag: MY_UPDATE_TAG,
},
);
// Listen for updates with specific tags
editor.registerUpdateListener(({tags}) => {
if (tags.has(MY_FEATURE_TAG)) {
// Handle updates from your custom feature
}
});Packages Lexical Website Docs Extensions Defining Extensions (`packages/lexical-website/docs/extensions/defining-extensions.md`)
Defining Extensions
An extension is an plain JavaScript object that complies with the
LexicalExtension interface.
The defineExtension function
is merely an identity function to make TypeScript inference convenient.
It will be compiled down to the following, and likely optimized out by your
bundler or minifier:
function defineExtension(extension) {
return extension;
}Extensions must be stable references. It is most useful to define them at
module scope, but if for some reason you have to define it inside of a
React component you'll need some way to make it stable such as withuseState, useMemo, or useRef as appropriate.
Required Properties
name
The only required property for an extension is name. The name is a string
that should be unique to that extension your editor. It is best practice to
use a scope for your project or organization to ensure that reusable
extensions don't conflict with each other.
The general practice in the lexical repository is to use the name of the
package as the extension name when it only makes sense to export a single
extension, (e.g. DragonExtension has the name @lexical/dragon), or
with an additional path if multiple extensions are exported (e.g.AutoFocusExtension has the name @lexical/extension/AutoFocus).
InitialEditorConfig
Extensions can specify configuration overrides for the editor, these are
defined by the
InitialEditorConfig
interface. See the API docs for the full list of properties. Every property
here has some default.
Merged properties
For an extension designed to be used as a dependency, it can also be useful
to specify properties that are merged such ashtml (override HTML import/export),nodes (register new nodes or node overrides with the editor),
or theme (specify classes to be used for nodes provided by lexical).
export const CodeExtension = defineExtension({
name: '@lexical/code',
nodes: () => [CodeNode, CodeHighlightNode],
});Root properties
There are other properties that are more useful for the "root" extension
that you provide to
buildEditorFromExtensions
or the extension prop of
LexicalExtensionComposer
where you would specify properties that can only be meaningfully set
once such as $initialEditorState, editable, onError, namespace or parentEditor.
There is no requirement that these properties be set from any specific level
of the extension hierarchy, or at all, but it is only meaningful to set them
once per editor.
const editor = buildEditorFromExtensions(
defineExtension({
name: "@example/basic-rich-text-editor",
namespace: "basic-rich-text-editor",
dependencies: [RichTextExtension],
register: (editor: LexicalEditor) => {
console.log("Editor Created");
return () => console.log("Editor Disposed");
},
}),
);Extension Dependencies
Lexical Extensions have two ways for extensions to depend on and specify
configuration for each other:
- dependencies are direct (by reference)
- peerDependencies are indirect (by name) and optional
The exception to this rule is with peerDependencies. These are optional
dependencies
dependencies
The dependencies property is an array of extensions by reference that your
extension depends on. For example, if your extension uses React then it should
depend on the ReactExtension.
Dependencies form a directed acyclic graph. This means that there must not
be any circular dependencies. If extension A depends on extension B then
there must not be any dependency path from B to A
(or from A to A).
This array can contain either direct references to extensions, or calls to
configExtension.
export const ExampleExtension = defineExtension({
name: "@example/extension",
dependencies: [
SomeExtension,
configExtension(ReactExtension, {
decorators: [<ExampleDecorator />]
}),
],
});peerDependencies
The peerDependencies property is an array of optional extensions by name.
They are not requirements, but specifying them here allows your extension
to find them at runtime and override configuration for them if they are
built with the editor. You can use
declarePeerDependency to
build this array with type inference.
Since these are optional and by name, an extension may declare a peer it does
not import. The graph must still be acyclic: peer edges take part in the same
topological sort as dependencies, so two extensions that peer-depend on each
other and are both present will fail to build.
This is rarely needed in practice and is considered an advanced use case,
typically to avoid a direct import or dependency loop. See
Peer Dependencies for more detail.
conflictsWith
The conflictsWith property is an array of extensions by name that are
known to conflict with this extension. For example, it does not make
sense to have
RichTextExtension and
PlainTextExtension
in the same editor.
export const PlainTextExtension = defineExtension({
conflictsWith: ['@lexical/rich-text'],
dependencies: [
DragonExtension,
NormalizeInlineElementsExtension,
NormalizeTripleClickSelectionExtension,
],
name: '@lexical/plain-text',
register: registerPlainText,
});This is rarely needed in practice and is considered an advanced use case,
but it can provide helpful early errors when it is needed.
Phases
The construction of an editor using extensions is done in sequential phases,
and the following properties are useful ways to hook into that process at
specific points.
config
config is an object that is the default configuration for your extension,
properties of this object can be overridden by other extensions using
configExtension or
declarePeerDependency.
This object is used in later phases to build the init and/or output.
The config phase happens before the editor is constructed.
mergeConfig
mergeConfig is a function that can be used to merge configuration
with its overrides if you need something more fine-grained than a
shallow object merge, such as concatenating arrays. For example:
interface StringArrayConfig {
array: string[];
}
const StringArrayExtension = defineExtension({
config: safeCast<StringArrayConfig>({array: []}),
name: "@example/StringArray",
mergeConfig(a, b) {
const config = shallowMergeConfig(a, b);
if (b.array) {
config.array =
b.array.length > 0
? [...a.array, ...b.array]
: a.array;
}
return config;
},
});The default implementation is
shallowMergeConfig,
most extensions will not need to override this.
init
The init phase happens before the editor is constructed, but after
the merged editor configuration is available and all extensions are
configured. It can be used to reference configuration
from peers, compute data that should be available during
build, and is generally a last resort for making mutations
to any extension or editor configuration before the editor is
created.
The result of this function is available in later phases.
This is rarely needed in practice and is considered an advanced use case.
build
The build phase happens after the editor is constructed — it receives theLexicalEditor — but before register, and after
config and init. The return value of the build
phase is output and is available for later phases.
output is generally how extensions provide functionality to each
other and to the nodes in your application. A very common use case is
to build signals from configuration with namedSignals so that the
behavior of your extension can be modified at runtime (e.g. disabled
is a very common use case).
Other use cases for build's output are to provide
shared data structures, typed theme configuration, references to the
commands that the extension implements, etc.
export interface TabIndentationConfig {
disabled: boolean;
maxIndent: null | number;
/**
* By default, indents are set on all elements for which the
* {@link ElementNode.canIndent} returns true. This option allows you to set
* indents for specific nodes without overriding the method for others.
*/
$canIndent: CanIndentPredicate;
}
export const TabIndentationExtension = defineExtension({
build(editor, config, state) {
return namedSignals(config);
},
config: safeCast<TabIndentationConfig>({
$canIndent: $defaultCanIndent,
disabled: false,
maxIndent: null,
}),
name: '@lexical/extension/TabIndentation',
register(editor, config, state) {
const {disabled, maxIndent, $canIndent} = state.getOutput();
return effect(() => {
if (!disabled.value) {
return registerTabIndentation(editor, maxIndent, $canIndent);
}
});
},
});register
This happens after the editor has been constructed. This is where you will
register any commands, listeners, etc. that your extension needs. It can use
the result of init or build via state.getInitResult() andstate.getOutput() respectively.
The return value is a dispose function, typically the result of mergeRegister.
afterRegistration
This happens after register for every extension has been called,
so all commands should be registered. This is the phase when the$initialEditorState is applied to the editor by InitialStateExtension,
so editor.setRootElement should be called no sooner than this phase (it may
of course be called after the editor is built, outside of extensions).
The return value is a dispose function, typically the result ofmergeRegister.
Packages Lexical Website Docs Extensions Design (`packages/lexical-website/docs/extensions/design.md`)
Design Doc
See also the initial PR #7706
Guiding principles
Minimize boilerplate
Wherever possible, sensible defaults should be provided and it must be
possible to override them. This is why a Extension has the config property
which specifies the initial configuration.
Full backwards compatibility
Almost anything that you do with the editor or a legacy React plug-in should
just work. Upgrading to use a Extension should be a simple change.
Minimize change required to add an Extension
Ideally, adding an extension should be two or three lines of code:
- Import it
- Add it as a dependency (with config if necessary)
- Add UI related code (only if needed, e.g. has to be rendered in a specific place in the DOM)
Avoid invalid states
TypeScript goes a long way here, but it's only as good as the API design.
For example, splitting up the workflow for an Extension to have separate Config,Init and Output types was something that evolved over time. In earlier
prototypes, Config was overloaded to maintain all of that state, but it
required a lot more runtime support and type assertions to check that
the state was properly initialized for that phase.
Try to avoid undecipherable type errors
There's only so much that can be done to make readable error messages
out of complex type expressions, so the API is designed to avoid them
when possible. See also microsoft/TypeScript#23689.
Inspiration
[ESLint Flat Plugins](https://eslint.org/docs/latest/extend/plugin-migration-flat-config)
These have many of the same constraints as this project, although of course
are used for a very different purpose
[MDXEditor](https://mdxeditor.dev/)
This editor is based on Lexical, but provides its own mechanism for plugins
based on a third party state management library. These plugins don't
really expose any type-safe configuration.
[@payloadcms/richtext-lexical](https://www.npmjs.com/package/@payloadcms/richtext-lexical)
Another Lexical based editor framework for use inside the
Payload CMS. It also doesn't really expose type-safe
configuration, but it does seem to have quite a lot of features and seems
well-designed for laziness and SSR. Features are conceptually similar to Extensions.
[Effect](https://effect.website/)
This library is pretty much state of the art for type safety with a focus on
composition and usability.
Tighter integration with LexicalEditor
This package was originally prototyped as an entirely optional feature,
as adoption increases it's expected that more of Lexical will directly
depend on it.
Intentional trade-offs
No compile-time support for dependency resolution
The current theory is that it would require too much TypeScript
in order to carry around the list of all dependency names that
exist in the graph, and would likely add another type argument
to LexicalExtension.
The features that this blocks are:
- Compile-time support for detecting Extension conflicts. Detecting that two
extensions defined with the same name (but are not identical object
references, because a dependency can be shared!) would also be quite the
undertaking in TypeScript. This is automatically detected at runtime by
the builder. - Compile-time support for required configuration without defaults.
An extension can implement this at runtime ininitorregister. - Compile-time support for required peer dependencies. A use case for this
would be the requirement of aReactProviderExtensionprovided by eitherLexicalExtensionComposerorReactPluginHostExtension. An extension can implement this
at runtime ininitorregister.
Generally speaking, all of these are already surfaced as runtime errors
while building the editor with sufficient information to quickly track
down the root cause.
Known Missing Features
Direct support for devtools
This is a TODO, the infrastructure was designed with this in mind.
Documented patterns for RSC/SSR/Headless
Having a known peer dependency that is used to declare SSR may help
guide future Extension development. In many cases there are decorator nodes
that have React dependencies that you do not want to (or can not) render
in an SSR context. For example, RSC can not be supported anywhere thatReact.Context is used which is everywhere that React is currently used
in Lexical.
Another option would be to build separate entrypoints for the SSR use case,
which is more of an RSC specific strategy, but we could encourage people
to use those import conditions even in a Vanilla JS or some other bespoke
SSR use case. That strategy may make it hard to support including both
"headless" and "non-headless" in the same module.
Packages Lexical Website Docs Extensions Faq (`packages/lexical-website/docs/extensions/faq.mdx`)
FAQ
What's the compatibility story?
All Legacy Plugins (React Components) are compatible with Lexical Extensions!
Lexical Extensions are only compatible with editors created using the
Lexical Extension API (buildEditorFromExtensions, or an abstraction that uses
it internally such as LexicalExtensionComposer).
See the Migration Guide for more detailed examples!
Why is it called an Extension instead of a Plugin?
Legacy reasons. Historically, Lexical has used the term Plugin to refer
to a React component that adds behavior to the editor. Transitioning
this word to another meaning would be confusing.
It would also be a tricky transition for public APIs, since the ideal
names for exports are already taken, so the legacy Plugin and the new
Extension could not be colocated if they were both called Plugin!
What about lazy loading?
Lazy loading does not need any special support! All of the functions
that you implement with extensions must be synchronous, but yourregister or afterRegistration function can perform an asynchronousimport(), network calls, etc. You just need to make sure that the
dispose function that you return can manage the cleanup appropriately.
You can also use getSignal() on the state to get an AbortSignal
which may be more convenient than tapping into the dispose function.
This is similar to how you would use React.lazy to defer loading the
implementation to when it is used.
See the extension-vanilla-tailwind example for a demonstration for
how this can be done.
:::note
The Extension itself must be synchronously imported before creating the
editor. Lexical needs all editor configuration (including nodes) up front.
This is no different than without Lexical Extension. The Lexical core would
need significant changes to allow configuration changes to an editor at
runtime.
:::
How do we prevent all of the frameworks from being loaded?
Vanilla JS and React have first class support in Lexical. Lexical Extensions
provides a path for Extensions written for either of these to be loaded into
any editor regardless of what framework the page is using.
When you depend on an Extension that depends on ReactExtension, you will get a runtime
error if a React provider is not present (LexicalExtensionComposer orReactPluginHostExtension). Think of it like an unsatisfied but required peer
dependency. This way you won't get confused if you add an extension that requires
React but it never renders correctly because React isn't set up properly.
Other frameworks can use the functionality exposed by Lexical Extensions
to provide their own integrations in a similar way. SupportingDecoratorNode subclasses in multiple frameworks is not currently
supported, or planned to be supported, because I am pretty sure that
you can use mutation listeners for the same purpose. Perhaps the
decorate method could even be deprecated at some point!
How will this interact with Lexical Dev Tools?
All of the Builder metadata is present at runtime, and is present
on the LexicalEditor instance with a well-known symbol so that the dev tools
can access it. There are many things that could be done, here are
a few examples:
- Show a diagram of the Extension tree
- Track which config came from which Extension (e.g. which node, which theme
property, etc.) - Track which commands/listeners/etc. were registered by which Extensions
(would require editor instrumentation)
Packages Lexical Website Docs Extensions Included Extensions (`packages/lexical-website/docs/extensions/included-extensions.md`)
Included Extensions
Framework-agnostic accessibility extensions. See Keyboard Accessibility for the full contract and the matching @lexical/react adapters.
- AriaLiveRegionExtension - Owns a visually hidden
aria-liveregion and exposes a stableannouncesink (WAI-ARIA status messages, WCAG 4.1.3) - EditorModeAnnounceExtension - Announces editable / read-only transitions through the live region
- FocusManagerExtension - Editor ↔ toolbar focus jump (Alt+F10 to the toolbar, Escape back to the editor), reference-counted per toolbar
- FocusTrapExtension - Traps Tab / Shift+Tab focus inside one or more containers (modal dialogs), reference-counted per container
- HistoryAnnounceExtension - Announces undo / redo through the live region
- RovingTabIndexExtension - WAI-ARIA roving-tabindex pattern for toolbars / groups, reference-counted per container
- ClipboardDOMImportExtension - Routes
text/htmlpastes and drops through theDOMImportExtensionpipeline (experimental) - ClipboardImportExtension - Configurable per-MIME-type clipboard import for paste and drop (experimental)
- GetClipboardDataExtension - Configurable serialization of the selection into clipboard MIME types for copy and drag
- CodeExtension - CodeNode (code blocks)
- CodeIndentExtension - CodeNode tab key indentation (code blocks)
- CodePrismExtension - Highlighting with Prism for CodeNode
- CodeShikiExtension - Highlighting with Shiki for CodeNode
- DragonExtension - Dragon (speech to text) support, included by default with
RichTextExtensionandPlainTextExtension
- AutoFocusExtension - Focus the editor when it is created (e.g. on page load)
- ClearEditorExtension - Implementation for the
CLEAR_EDITOR_COMMAND - ClickAfterLastBlockExtension - Inserts a paragraph when clicking below a last block that can not accommodate the click (e.g. a DecoratorNode or TableNode)
- DecoratorTextExtension - DecoratorTextNode support, sets the format and CSS classes for the DOM container
- EditorStateExtension - Provide EditorState as a signal (alternative to
registerUpdateListener) - HMRExtension - Preserves editor state, selection, editability and undo/redo history across Hot Module Replacement (HMR) cycles
- HorizontalRuleExtension - HorizontalRuleNode (
<hr>tag) - IMEExtension - Centralizes IME composition state as signals
- InitialStateExtension - Sets the initial state of the editor (always included)
- KeyboardShortcutsExtension - Dispatches a table of keyboard shortcuts from a single compiled
KEY_DOWN_COMMANDlistener; the table is merged across the extension graph by name (experimental) - NestedEditorExtension - Configures an editor as a nested editor of a parent editor (theme inheritance, optionally editable state)
- NodeSelectionExtension - Tracks selection, typically for DecoratorNodes
- NodeSelectionDataSelectedExtension - Mirrors a node's
NodeSelectionmembership onto its host DOM as adata-selectedattribute so CSS can outline selectedElementNodehosts; configured per node type (experimental) - NormalizeInlineElementsExtension - Removes empty inline elements, included by default with
RichTextExtensionandPlainTextExtension - NormalizeTripleClickSelectionExtension - Corrects over-selection after triple click events, included by default with
RichTextExtensionandPlainTextExtension - PreventSelectAllExtension - Prevents select all (Ctrl/Cmd+A) inside input/textarea elements from selecting the editor content, included by default with
SelectBlockExtension - RootElementExtension - Exposes the editor's root element as a reactive
Signal<HTMLElement | null> - SelectBlockExtension - Select all (Ctrl/Cmd+A) selects the nearest block element first, pressing it again selects the whole document
- SelectionAlwaysOnDisplayExtension - Highlights selected content even when the editor is not focused
- TabIndentationExtension - Changes Tab key to insert tabs and indent instead of natively focusing the next field
- WatchEditableExtension - Exposes the editor's editable state as a reactive
Signal<boolean>
- HashtagExtension - HashtagNode
- HistoryExtension - History support (undo/redo)
- SharedHistoryExtension - History sharing between a parent editor and its nested editors
- CoreImportExtension - DOM import rules for the core nodes, included implicitly by node-providing extensions (experimental)
- DOMImportExtension - Extension-based replacement for
importDOM/DOMConversion, rules are compiled into a dispatcher at editor build time (experimental) - DOMRenderExtension - Overrides the DOM render and export behavior for an editor (experimental)
- AutoLinkExtension - AutoLinkNode, note that matchers have to be manually configured
- ClickableLinkExtension - Toggle between lexical selection and native click to open behavior for links
- LinkExtension - LinkNode (
<a>tag)
- ListExtension - ListNode, ListItemNode
- CheckListExtension - Checklist support for ListNode and ListItemNode
- MarkExtension - MarkNode
Experimental micromark/mdast-based Markdown support (every extension in this package is experimental). See Markdown with @lexical/mdast for the full story.
- MdastExtension - The configurable core registry, exposing both Markdown import and export
- MdastHeadingExtension - ATX & setext headings (HeadingNode)
- MdastBlockquoteExtension - Block quotes (QuoteNode)
- MdastRichTextExtension - Bundle of heading + blockquote
- MdastListExtension - Ordered/unordered lists (ListNode, ListItemNode)
- MdastTaskListExtension - GFM task lists (
- [x]), layered on MdastListExtension - MdastCodeExtension - Fenced & indented code blocks (CodeNode)
- MdastLinkExtension - Links, CommonMark autolinks, reference links (LinkNode)
- MdastAutolinkLiteralExtension - GFM literal autolinks (bare URLs in prose)
- MdastHorizontalRuleExtension - Thematic breaks (HorizontalRuleNode)
- MdastStrikethroughExtension - GFM strikethrough (text format)
- MdastTableExtension - GFM tables (TableNode, ...)
- MdastCommonMarkExtension - Bundle of the six CommonMark extensions
- MdastGfmExtension - Bundle of the four GFM extensions
- MdastShadowRootQuoteExtension - Opt-in: blockquotes as block containers with full-fidelity nested content
- MdastHtmlExtension - Opt-in: raw HTML in Markdown routed through the
@lexical/htmlDOM import rules (Markdown inside the tags included, GitHub-style), plus generic HTML-encoded export via$exportViaDOM/rawHtmlBlock - MdastShortcutsExtension - Streaming Markdown typing shortcuts, driven by the same grammar as import
- OverflowExtension - OverflowNode
- PlainTextExtension - Plain text editor, the return key creates a LineBreakNode by default (one ParagraphNode per document)
@lexical/react
- RichTextExtension - Rich Text editor (QuoteNode, HeadingNode), the return key creates a ParagraphNode by default (multiple ParagraphNode per document). Includes configurable
escapeFormatTriggersto escape text formatting (e.g. code) at text node boundaries, andshouldHandlePasteAsFilesto control whether files win over incidental text/HTML clipboard entries on paste
- TableExtension - TableNode, TableRowNode, TableCellNode
- TailwindExtension - A theme configuration that uses Tailwind classes
Packages Lexical Website Docs Extensions Migration (`packages/lexical-website/docs/extensions/migration.mdx`)
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
Migration Guide
Migrating to Lexical Extensions is designed to be seamless! Lexical Extensions is
a higher-level API on top of the existing functionality you are already using.
Generally speaking, the only thing that needs to change is how you create the
editor. Everything else can be migrated (or not) at your own leisure, but
the result will be simpler and more composable if you do!
Vanilla JS App
import {registerDragonSupport} from '@lexical/dragon';
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {HeadingNode, QuoteNode, registerRichText} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils';
// highlight-next-line
import {createEditor} from 'lexical';
import $prepopulatedRichText from './$prepopulatedRichText';
const editorRef = document.getElementById('lexical-editor');
const stateRef = document.getElementById(
'lexical-state',
) as HTMLTextAreaElement;
// highlight-next-line
const editor = createEditor({
namespace: 'Vanilla JS Demo',
// Register nodes specific for @lexical/rich-text
nodes: [HeadingNode, QuoteNode],
// highlight-start
onError: (error: Error) => {
throw error;
},
// highlight-end
theme: {quote: 'PlaygroundEditorTheme__quote'},
});
editor.setRootElement(editorRef);
// Registering Plugins
mergeRegister(
registerRichText(editor),
registerDragonSupport(editor),
registerHistory(editor, createEmptyHistoryState(), 300),
);
editor.update($prepopulatedRichText, {tag: 'history-merge'});import {registerDragonSupport} from '@lexical/dragon';
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {HeadingNode, QuoteNode, registerRichText} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils';
// highlight-next-line
import {buildEditorFromExtensions} from '@lexical/extension';
import $prepopulatedRichText from './$prepopulatedRichText';
const editorRef = document.getElementById('lexical-editor');
const stateRef = document.getElementById(
'lexical-state',
) as HTMLTextAreaElement;
// highlight-next-line
const editor = buildEditorFromExtensions({
// Any string is suitable as long as it uniquely defines the Extension in the editor
name: '[root]',
namespace: 'Vanilla JS Demo (with Lexical Extension)',
// Register nodes specific for @lexical/rich-text
nodes: [HeadingNode, QuoteNode],
// highlight-next-line
// onError boilerplate removed
theme: {quote: 'PlaygroundEditorTheme__quote'},
});
editor.setRootElement(editorRef);
// Registering Plugins
mergeRegister(
registerRichText(editor),
registerDragonSupport(editor),
registerHistory(editor, createEmptyHistoryState(), 300),
);
editor.update($prepopulatedRichText, {tag: 'history-merge'});import {buildEditorFromExtensions} from '@lexical/extension';
import {HistoryExtension} from '@lexical/history';
import {RichTextExtension} from '@lexical/rich-text';
import $prepopulatedRichText from './$prepopulatedRichText';
const editorRef = document.getElementById('lexical-editor');
const editor = buildEditorFromExtensions({
// highlight-start
// This works similarly to LexicalComposer editorState
$initialEditorState: $prepopulatedRichText,
// highlight-end
name: '[root]',
namespace: 'Vanilla JS Demo (all-in with Lexical Builder)',
// highlight-start
// RichTextExtension has a nodes property to add QuoteNode and HeadingNode
// DragonExtension is a dependency of RichTextExtension
// All three extensions have register properties to add behavior to the editor,
// with defaults for all of the History configuration
dependencies: [RichTextExtension, HistoryExtension],
// highlight-end
theme: {quote: 'PlaygroundEditorTheme__quote'},
});
editor.setRootElement(editorRef);React App
/* Detailed source-code truncated for AI context efficiency. *//* Detailed source-code truncated for AI context efficiency. */React Plug-in (without UI)
/* Detailed source-code truncated for AI context efficiency. *//* Detailed source-code truncated for AI context efficiency. *//* Detailed source-code truncated for AI context efficiency. */Packages Lexical Website Docs Extensions Peer Dependencies (`packages/lexical-website/docs/extensions/peer-dependencies.md`)
Peer Dependencies
Peer Dependencies are used to declare indirect or optional dependencies
between extensions. This can be used to enable some functionality or
configuration only when some other extension is available, or to
avoid a dependency loop. Extensions must form a directed acyclic graph,
there must not be a dependency path from one extension back to itself.
Use Cases
Generally the intended use case for peerDependencies is to provide
functionality only when some other extension is present. For example,
you may have an extension that requires special code to interact with
React, but is also usable without React, so you could haveReactProviderExtension in the peerDependencies array.
Another example would be to provide Markdown serialization or import
for an extension that provides some custom node type, but only
if the MdastExtension is present in that editor.
Packages Lexical Website Docs Extensions React (`packages/lexical-website/docs/extensions/react.md`)
React and Lexical Extension
Lexical Extension vastly improved support for non-React usage, but
did not make any compromises when supporting React.
Minimal migration to LexicalExtensionComposer
Migrating your application from LexicalComposer toLexicalExtensionComposer is straightforward, but has
two things to consider.
Convert your `initialConfig` to an `extension`
Before:
const initialConfig = {
namespace: 'MyEditor',
nodes: [QuoteNode, HeadingNode],
theme,
editorState(editor) {
editor.update($initialEditorState, {tag: HISTORY_MERGE_TAG});
},
// This is the default when using Lexical Extension
onError(err) {
throw err;
},
};After:
// Be careful to make sure that this is a stable reference, either by defining it
// at the top-level of a module or by using something like `useRef` or `useState`
// to create it. Any time this object changes, the editor will be recreated.
const appExtension = defineExtension({
name: 'MyEditor',
namespace: 'MyEditor',
nodes: () => [QuoteNode, HeadingNode],
theme,
$initialEditorState,
});Switch from LexicalComposer to LexicalExtensionComposer
Before:
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={<ContentEditable />}
ErrorBoundary={LexicalErrorBoundary}
/>
{/* other legacy React plugins */}
</LexicalComposer>After:
<LexicalExtensionComposer extension={appExtension} contentEditable={null}>
<RichTextPlugin
contentEditable={<ContentEditable />}
ErrorBoundary={LexicalErrorBoundary}
/>
{/* other legacy React plugins */}
</LexicalExtensionComposer>This is a minimal migration, in most cases you can migrate plug-in usage to
extension dependencies. For example, by using
RichTextExtension instead ofRichTextPlugin:
const appExtension = defineExtension({
name: 'MyEditor',
namespace: 'MyEditor',
dependencies: [RichTextExtension],
theme,
$initialEditorState,
});<LexicalExtensionComposer extension={appExtension}>
{/* By default the contentEditable is the first child, defaulting to <ContentEditable /> */}
{/* other legacy React plugins */}
</LexicalExtensionComposer>Managing the editor yourself with LexicalExtensionEditorComposer
LexicalExtensionComposer builds its editor from extension and disposes it
when the component unmounts. When you want to own that lifecycle instead,
such as a nested editor that belongs to a node (like the caption in the
playground's StickyComponent), an editor shared with non-React code, or one
that should outlive the component, build the editor yourself and render it
withLexicalExtensionEditorComposer.
The editor must include ReactProviderExtension and ReactExtension, whichLexicalExtensionComposer otherwise adds for you:
import {buildEditorFromExtensions, defineExtension} from '@lexical/extension';
import {LexicalExtensionEditorComposer} from '@lexical/react/LexicalExtensionEditorComposer';
import {ReactExtension} from '@lexical/react/ReactExtension';
import {ReactProviderExtension} from '@lexical/react/ReactProviderExtension';
import {RichTextExtension} from '@lexical/rich-text';
// Built once, at module scope, so it is not re-created on every render
const editor = buildEditorFromExtensions(
defineExtension({
dependencies: [ReactProviderExtension, ReactExtension, RichTextExtension],
name: '@my-app/editor',
}),
);
function App() {
return (
<LexicalExtensionEditorComposer initialEditor={editor}>
{/* The ContentEditable is rendered by ReactExtension, as with LexicalExtensionComposer */}
{/* other legacy React plugins */}
</LexicalExtensionEditorComposer>
);
}LexicalExtensionEditorComposer never disposes the editor, so calleditor.dispose() yourself when you are done with it. Keep theinitialEditor prop stable (module scope, useMemo, or state held by a
node), and render a given editor in only one place at a time, since an editor
has only one root element.
React Plug-ins (Legacy)
These just work as-is, by rendering them as children of your composer. You may
wish to package them as an extension for easier re-use, or consider refactoring
them to work without React. See Extension Decorators
and Output Components, and
React Independence for strategies to repackage or
refactor your legacy plug-ins.
Extension Decorators
A common use case for legacy react plug-ins is to render some React component,
but the position of that component in the document is not important. Some use
cases for this are when the component is simply an effect that always returnsnull, or it is rendering something like a portal. There's no need for users
to manually decide where these components are rendered, so Lexical's
ReactExtension maintains an array of these to render automatically.
// This is exactly how you would write a legacy React plug-in
function LogEditorPlugin() {
const [editor] = useLexicalComposerContext()
useEffect(() => {
console.log(editor);
}, [editor]);
return null;
}
// This is how you would declare the plugin as an extension
// that depends on React and renders this plug-in as a decorator
export const LogEditorExtension = defineExtension({
name: "@example/LogEditor",
dependencies: [
configExtension(ReactExtension, {
decorators: [<LogEditorPlugin />]
}),
],
});Output Components
In situations where it is important for the user to decide exactly where an
extension's component is rendered in the React document, the convention is
to provide an Output Component.
Here we have the TreeViewExtension which defines an Output Component to render
the TreeView with the specified configuration.
export function TreeViewExtensionComponent(
props: Partial,
): JSX.Element {
const [editor] = useLexicalComposerContext();
return (
);
}
export const TreeViewExtension = defineExtension({
build: () => ({Component: TreeViewExtensionComponent}),
config,
dependencies: [ReactExtension],
name: '@lexical/react/TreeView',
});This extension component can be acquired with the
useExtensionComponent
hook or the ExtensionComponent component.
return (
<ExtensionComponent
lexical:extension={TreeViewExtension}
viewClassName="tree-view-output" />
);const TreeViewComponent = useExtensionComponent(TreeViewExtension);
return ();React Independence
Many legacy plug-ins are merely a useEffect wrapper, these tend to not
need React at all. For example:
Before:
export function CheckListPlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return registerCheckList(editor);
}, [editor]);
return null;
}After:
export const CheckListExtension = defineExtension({
name: "@lexical/list/CheckList",
register: registerCheckList,
});Using React extensions and plug-ins without JSX
ReactPluginHostExtension
is an extension that allows you to mount the a React root at a
specific DOM element in your app so that applications that are
not natively React can still take advantage of existing legacy
plug-ins and extensions that depend on React.
While this does mean your application will include the React runtime, you
don't have to use JSX or have any other React infrastructure in your app.
See
extension-vanilla-react-plugin-host
for usage of this extension and its API
Packages Lexical Website Docs Extensions Signals (`packages/lexical-website/docs/extensions/signals.md`)
Signals
Lexical Extensions uses signals as the reactive interface that you can use to
communicate with and between extensions.
Motivation
At the lowest level, communicating between parts of Lexical is done with
callbacks such as registerCommand, registerUpdateListener,registerMutationListener, and addEventListener with the DOM.
How these callbacks behave and in some cases whether they are registered
at all depends on state local to the extension that may also be mutable,
so would require a similar callback mechanism with a naïve implementation.
With legacy React plug-ins, this mechanism is useEffect which is very
coarse and doesn't really have a portable equivalent when using other
or no framework.
The initial prototype was largely callback based as well, but this quickly
becomes very difficult to work with when you have to do work based on
the "result" of multiple callbacks or configuration values. Signals are
a more general solution to this problem. There's even a
TC39 Signals Proposal to add
infrastructure for this to the JavaScript standard.
Signals also allow for optimizations by reducing redundant callbacks with
primitives such as batch or by allowing the current value to be inspected
with peek without adding any subscription. Basically, the same Signal can
be used either like React State when you want to run code as the value changes
over time, or like a React Ref when you only need to know the current value
(as is typical with event handlers).
Signals also do not require you to manage a dependency array or use a
compiler, the subscriptions and dependencies are inferred at runtime
as they are used. This means that the set of signals subscribed to by a
given effect can change without violating any rules or generating any
warnings.
Implementation
Since Signals are not yet standardized, and the
signal-polyfill is not
ready for production use, it was decided to leverage
@preact/signals-core. This
implementation is dependency-free, optimized, and well tested.
For future compatibility we do not recommend that you use@preact/signals-core directly, and instead use the re-exports from
@lexical/extension.
As of the initial release, these re-exports are:
export {
batch,
computed,
effect,
type ReadonlySignal,
type Signal,
signal,
type SignalOptions,
untracked,
} from '@preact/signals-core';Usage
For thorough examples on how the signal primitives work, you ca