This file provides detailed guidance for AI agents and automated tools working with the Lexical codebase.
pnpm run build- Build all packages in development modepnpm run build-prod- Clean and build all packages in production modepnpm run build-release- Build production release with error codespnpm run build-types- Build TypeScript type definitions and validate them
pnpm run test-unit- Run all unit tests (Vitest, Node/jsdom)pnpm run test-unit-watch- Run unit tests in watch modepnpm run test-browser- Run browser-mode unit tests (Vitest + Playwright, real browser)pnpm run test-browser-watch- Run browser-mode tests in watch modepnpm run test-e2e-chromium- Run E2E tests in Chromium (requires dev server running)pnpm run test-e2e-firefox- Run E2E tests in Firefoxpnpm run test-e2e-webkit- Run E2E tests in WebKitpnpm run debug-test-e2e-chromium- Run E2E tests in debug mode (headed)pnpm run debug-test-unit- Debug unit tests with inspector
For E2E testing workflow:
- Start the dev server:
pnpm run start(orpnpm run devif you don't need collab) - In another terminal:
pnpm run test-e2e-chromium
pnpm run start- Start playground dev server + collab server (http://localhost:3000)pnpm run dev- Start only the playground dev server (no collab)pnpm run start:website- Start Docusaurus website (http://localhost:3001)pnpm run collab- Start collab server on localhost:1234
pnpm run lint- Run ESLint on all filespnpm run lint:fix- Auto-fix lint issuespnpm run prettier- Check code formattingpnpm run prettier:fix- Auto-fix formatting issuespnpm run flow- Run a full foreground Flow check (does not reuse a server)pnpm run tsc- Run TypeScript compilerpnpm run ci-check- Run all checks (TypeScript, Flow, Prettier, ESLint)
Never commit changes to scripts/error-codes/codes.json.
That edit is not yours to make — revert it to the state of
main before staging, and never git add the file.
Prefer ast-grep over line-oriented regex (grep/sed) for anything
structural — matching or rewriting imports, call sites, JSX, type
annotations, etc. Regexes miss multi-line forms (a symbol on its own line
inside a multi-line import { ... } block) and produce false positives
(IS_APPLE matching inside IS_APPLE_WEBKIT); ast-grep matches the syntax
tree, so it does neither.
It isn't a dependency, so run it via npx (the CLI binary is ast-grep, not
the shadow-utils sg that may be on PATH):
# Find every import of something from '@lexical/utils' (ts and tsx are
# separate grammars, so pass -l for each; add --json=compact to post-process)
npx --package @ast-grep/cli ast-grep run \
-p "import { \$\$\$NAMES } from '@lexical/utils'" -l ts packagesMetavariables ($NAME, $$$LIST) capture nodes for reporting or rewriting
with --rewrite. Reach for it whenever a change spans many files and must be
precise — e.g. moving symbols that @lexical/utils merely re-exports back to
a direct lexical import.
Import from the @lexical/extension barrel across package boundaries. Within
packages/lexical-extension, use relative imports and re-exports. Do not
manually rewrite these source imports to @lexical/extension/*: ESLint
enforces the source convention, and @lexical/compiler's subpathImports
pass rewrites them in all published package builds, including www. Public
sibling entries stay external so shared modules such as signals retain one
instance. Helpers with only one owning entry live under src/internal and
are bundled into that entry, without exposing additional public subpaths.
Downstream users may import either the barrel or public subpaths.
Module-scope calls to the side-effect-free factories (defineExtension,
configExtension, safeCast, createCommand, createState,
defineImportRule, etc.) need a /* @__PURE__ */ annotation so bundlers
can drop unused definitions from application bundles. Do not write those
annotations by hand — they are injected at build time by
@lexical/compiler (packages/lexical-compiler), which
runs as a Rollup plugin in scripts/build.mjs for the published bundles
and as a Vite plugin in scripts/vite/lexicalMonorepoPlugin.ts for
everything the monorepo builds from source. The published package is also
what consumers add to their own build when they compile Lexical from its
source export condition rather than from dist.
A hand-written annotation on one of these calls is a lint error
(@lexical/internal/no-pure-annotation), and the rule is autofixable, so a
branch written before the transform existed migrates with
pnpm run lint:fix. Annotations on anything else — a third-party factory, or
a call inside a function body, where the build never injects one — are left
alone.
When adding a new factory of this kind, annotate its definition with
@__NO_SIDE_EFFECTS__. That alone is enough for calls in the same package to
be annotated; add its name to PURE_FACTORY_FUNCTIONS in
packages/lexical-compiler/src/LexicalCompiler.mjs so that
calls in code that imports it by package name are too. An object whose
methods build values and touch nothing else (like @lexical/html's sel) is
marked @lexical-pure-namespace instead, so that sel.tag('p') is annotated
the way a factory call is.
The build runs with strict, which fails on a call inside one of these
definitions that nothing has established is side-effect free — such a call
pins the definition into every bundle that imports the module, however well
annotated the definition itself is. If it stops you, either make the call
lazy (a nodes: () => [...] callback is not evaluated at module scope), or
declare the function side-effect free so its calls are annotated too. A
third-party factory that cannot be declared is annotated by hand at the call
site, which is what the remaining hand-written annotations in the tree are.
Factory calls are not the only module-scope statements that pin a module.
esbuild and webpack keep, as a side effect, any property read at module scope
(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 = ...), a class with
a computed member ([SOME_SYMBOL]?: number — write declare on it), and an
IIFE — together with everything the statement references. One
LexicalEditor.version = ... after the class kept nearly all of lexical in
a bundle that imported only createCommand (#9120). Move such work into a
function declared @__NO_SIDE_EFFECTS__ and call it at module scope, so the
build annotates the call; annotate a call to a builtin or to a third-party
function (/* @__PURE__ */ forwardRef(...)) by hand. Two tests enforce this
by bundling a bare import 'pkg' of every entry with esbuild and requiring
that nothing is retained: scripts/__tests__/unit/treeShakingSource.test.ts
on the sources (runs with pnpm run test-unit) and
scripts/__tests__/integration/tree-shaking.test.mjs on the published
builds. A failure prints the retained code; its first statement is usually
the culprit.
A factory whose body is a trivial expression over its own arguments (like
safeCast, defineExtension, or configExtension) is additionally marked
@lexical-inline <form> and listed in that file's INLINE_FACTORIES: the
build replaces calls to it with the expression it would have returned, which
needs no annotation at all. The body of a marked function is reproduced by
the build, so keep it trivial — packages/lexical-compiler's unit
tests compare what the transform emits against what the real function returns
and will fail if the two drift apart.
Lexical is built around several key architectural concepts that work together:
Editor Instance - Created via createEditor(), wires everything together. Manages the EditorState, registers listeners/commands/transforms, and handles DOM reconciliation.
EditorState - Immutable data model representing the editor content. Contains:
- A node tree (hierarchical structure of LexicalNodes)
- A selection object (current cursor/selection state)
- Fully serializable to/from JSON
$ Functions Convention - Functions prefixed with $ (e.g., $getRoot(), $getSelection()) can ONLY be called within:
editor.update(() => {...})- for mutationseditor.read(() => {...})- for read-only access- Node transforms and command handlers (which have implicit update context)
This is similar to React hooks' restrictions but enforces synchronous context instead of call order.
Double-Buffering Updates - When editor.update() is called:
- Current EditorState is cloned as work-in-progress
- Mutations modify the work-in-progress state
- Multiple synchronous updates are batched
- DOM reconciler diffs and applies changes
- New immutable EditorState becomes current
Node Immutability & Keys - All nodes are recursively frozen after reconciliation. Node methods automatically call node.getWritable() to create mutable clones. All versions of a logical node share the same runtime-only key, allowing node methods to always reference the latest version from the active EditorState.
This is a monorepo with packages in packages/:
Core Packages:
lexical- Core framework (Editor, EditorState, base nodes, selection, updates)@lexical/react- React bindings (LexicalComposer, plugins as components)@lexical/headless- Headless editor for server-side/testing
Feature Packages (extend core with nodes/commands/utilities):
@lexical/rich-text- Rich text editing (headings, quotes, etc.)@lexical/plain-text- Plain text editing@lexical/extension- Extend editor functionality@lexical/list- List nodes (ordered/unordered/checklist)@lexical/table- Table support@lexical/code- Code block with syntax highlighting@lexical/link- Link nodes and utilities@lexical/markdown- Markdown import/export@lexical/html- HTML serialization@lexical/history- Undo/redo@lexical/yjs- Real-time collaboration via Yjs- And many more...
Development Packages:
lexical-playground- Full-featured demo applicationlexical-website- Docusaurus documentation site
Extensions - Extensions should be used to add features and configuration
to an editor. The set of extensions in an editor must be determined when the
editor is created with buildEditorFromExtensions. Extensions with
functionality that can be toggled on or off typically have a disabled
configuration property and output signal that defaults to false. See the
lexical-extension package and the supporting code in lexical for
more examples and implementation details.
export interface MyConfig {
disabled: boolean;
}
export const MyExtension = defineExtension({
build: (_editor, config, _state) => namedSignals(config),
config: safeCast<MyConfig>({ disabled: false }),
name: '@lexical/docs/My',
nodes: () => [MyNode],
register: (editor, _config, state) => {
const {disabled} = state.getOutput();
return effect(() => {
if (!disabled.value) {
return editor.registerUpdateListener(({editorState}) => {
// React to updates
});
}
})
},
})Plugin System (React) - Plugins are a legacy pattern for React components to hook into the editor lifecycle, extensions should be preferred for new code:
function MyPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerUpdateListener(({editorState}) => {
// React to updates
});
}, [editor]);
return null;
}Command Pattern - Commands are the primary communication mechanism:
- Create with
createCommand() - Dispatch with
editor.dispatchCommand(command, payload) - Handle with
editor.registerCommand(command, handler, priority) - Handlers propagate by priority until one stops propagation
Node Transforms - Registered via editor.registerNodeTransform(NodeClass, transform). Called automatically during updates when nodes of that type change. Have implicit update context.
Listeners - All editor.register*() methods return cleanup functions for easy unsubscription.
This codebase uses both TypeScript and Flow:
- Source files are primarily TypeScript (
.ts,.tsx) - Flow type definitions are generated in
packages/*/flow/directories - Run
pnpm run flowto check Flow types - Run
pnpm run tscto check TypeScript types - Both are checked in CI via
pnpm run ci-check
When adding/modifying APIs, types must be maintained for both systems.
All changes MUST be backwards compatible. Lexical is a widely-adopted OSS library, and breaking changes ripple out to every downstream consumer.
- Do NOT remove or rename existing public APIs, exported functions, types, or
$functions. Add new APIs alongside the old ones instead. - Do NOT change the signature, return type, or behavior of existing public APIs in ways that could break callers. Prefer additive, optional parameters.
- Preserve the serialization format of
EditorStateand node JSON. Serialized content produced by older versions must continue to deserialize correctly. - If an API genuinely must change, deprecate the old one first (keep it working, document the replacement) rather than removing it outright.
- When in doubt, assume external code depends on the current behavior and keep it intact.
An application must resolve exactly one copy of lexical, and one copy of each
@lexical/* package it uses. The active editor and editor state are
module-scope variables in LexicalUpdates.ts, node registration and
instanceof LexicalNode compare class references, and commands are object
identities from createCommand() — none of which survive a second copy of the
module. Two copies also mean two versions, which is API drift inside one editor.
The boundary is the app, not the page: several self-contained apps, each with
its own bundled Lexical, can coexist on one page (isLexicalEditor is an
instanceof check precisely to support that). They just cannot interoperate —
nodes, editors, EditorStates, selections and commands must never be passed
across such a boundary, only serialized data.
- Inside this monorepo, packages depend on siblings with
"lexical": "workspace:*";pnpm publishrewrites that to the exact published version, so every@lexical/*release pins the matchinglexical. Keep that pattern when adding a package — do not hand-write a version range. - A library published on top of Lexical (anything outside this repo that
imports
lexicalor@lexical/*) declares those packages inpeerDependencies, plusdevDependenciesfor its own build and tests, never independencies. Apps bring their own Lexical and own the version. - Code under
examples/andpackages/lexical-playgroundare applications, so they depend on Lexical directly — that is correct and should stay.
The user-facing version of this rule lives in
packages/lexical-website/docs/concepts/one-lexical-per-app.md; keep the two in
sync when either changes.
editor.read(...)oreditor.read('force-commit', ...)flushes pending updates first, then provides consistent reconciled stateeditor.read('pending', ...)reads the pending state (likeeditor.update(...), but read-only)editor.read('latest', ...)reads the latest consistent reconciled state- Inside
editor.update(), you see pending state (transforms/reconciliation not yet run) editor.getEditorState().read()always uses latest reconciled state, but prefereditor.read('latest', ...)in new code- Updates can be nested:
editor.update(() => editor.update(...))is allowed but strongly discouraged - Do NOT nest updates in reads, or use a force-commit in an update
Always access node properties/methods within read/update context. Nodes automatically resolve to their latest version via their key. Don't store node references across update boundaries.
- Unit tests - Vitest, located in
packages/**/__tests__/unit/**/*.test.{ts,tsx}. Add// @vitest-environment nodeafter the copyright header for tests that only exercise editor state, headless behavior, or pure utilities. DOM tests use jsdom withvmThreadson Node 24.9+, which reuses the jsdom implementation while keeping each file's window and module graph isolated. Older Node versions use isolated forks because their VM contexts cannot load jsdom'srequire(esm)dependencies. Platform-mocked tests depend on module isolation. Compiler tests run withscripts-unitin ordinary Node workers because native build tools are not VM-realm-safe. UseDOMParserto parse HTML in DOM tests and iframes for separate-window tests. Reserve direct JSDOM construction for Node fixtures or CSSOM behavior that jsdom's DOMParser lacks. - Browser tests - Vitest browser mode driven by the Playwright runner, located in
packages/**/__tests__/browser/**/*.test.{ts,tsx}. Use these for behavior that depends on a real layout/selection engine instead of stubbing the missing jsdom functionality fromvitest.setup.mts(e.g.Range.getBoundingClientRect, the Selection API). Run withpnpm run test-browser; the browser set is controlled by theVITEST_BROWSERenv var (comma-separated, defaultchromium). Prefer building editors with the extension APIs (buildEditorFromExtensions, orLexicalExtensionComposer/LexicalExtensionEditorComposerin React).- Do not use
using/Disposablein browser tests (or any browser-facing code). Explicit Resource Management (using,Symbol.dispose,Disposable) is not supported in WebKit/Safari yet, so the syntax throws aSyntaxErrorthere.usingis fine in unit tests (jsdom/Node), but browser tests should clean up withonTestFinished(() => editor.dispose())instead —editor.dispose()is a plain method available on the result ofbuildEditorFromExtensions.
- Do not use
- E2E tests - Playwright, located in
packages/lexical-playground/__tests__/e2e/**/*.spec.{ts,mjs} - E2E tests require the playground dev server running
- Use
pnpm run debug-test-e2e-chromiumto debug E2E tests with browser UI
When creating custom nodes:
- Extend a base node class (TextNode, ElementNode, DecoratorNode). The constructor must work with no arguments.
- Implement
$config()withthis.config('type', {extends: BaseNode, json: schema}), whereschemais built withnodeSchema<YourNode>()({...})as a module-scopeconstabove the class. Written inline in$config(), it makes TypeScript skip checking the class without a diagnostic. Declare every property inherent to the node in it, and declare each one stored verbatim in a field withwithField(stringValue(), {field: '__name'}). Initialize that field in the class (__name: string = '';) so it is an own property of a fresh node. UsewithAccessorsonly where a property is not a plain field. - Do not hand-write
clone,importJSON,exportJSONorupdateFromJSON:$configderives them from the schema, and it carries every property declared withwithFieldacross a clone. A field missing from the schema is not carried, so it is lost the first time a later update clones the node; declare it withwithFieldrather than writingafterCloneFrom. Ad-hoc data that is not inherent to the node type belongs in NodeState (createState), which clones on its own. WriteafterCloneFromonly for storage neither covers, such as a property declared through accessors on both sides whose value is not one field (one that is can name it:setter: {field: '__ids', method: 'setIDs'}inMarkNode). Callsuper.afterCloneFrom(prevNode)first, and copy every field of the class, schema fields included: Lexical does not add to a class's ownafterCloneFrom. - Implement
createDOM()andupdateDOM()(anddecorate()for a DecoratorNode). KeepexportDOM()on the class when the default export is not enough. - For HTML import, prefer
defineImportRulerules registered by the extension that provides the node: listCoreImportExtensionandconfigExtension(DOMImportExtension, {rules: [...]})in itsdependencies(seeListExtension, andListImportRulesfor the rules, in@lexical/list). Both APIs are@experimental, and the rules only apply where the editor routes HTML through the pipeline; seepackages/lexical-website/docs/serialization/dom-import.md.importDOM(), which that page calls legacy, is not deprecated yet, but its deprecation is planned. - Register with an extension (
nodes: () => [YourNode]) or editor config. - Export a
$createYourNode()factory built on$create(YourNode)and the node's setters, and a$isYourNode()guard.
The full schema reference is
packages/lexical-website/docs/serialization/serialization.md; the cloning
rules are in packages/lexical-website/docs/concepts/node-cloning.md.
Lexical supports editors whose root element lives inside a Shadow DOM or an
<iframe> document. The editor resolves its window and document from
rootElement.ownerDocument.defaultView, so code that reaches for the global
window or document will silently use the wrong realm when the editor crosses
a frame boundary. Shadow DOM adds a second hazard: the browser retargets
selection and focus reads to the shadow host, hiding the real nodes.
Use the shadow/iframe-aware helpers exported from lexical instead of the raw
browser globals:
| Instead of | Use | Why |
|---|---|---|
window |
element.ownerDocument.defaultView or getDefaultView(element) (@internal) |
Returns the window that owns the element |
window.getSelection() |
getDOMSelection(rootElement.ownerDocument.defaultView) |
Reads selection from the correct window |
selection.getRangeAt(0) |
getDOMSelectionRange(selection, rootElement) |
Unwraps retargeted shadow-DOM selection |
document in createDOM/updateDOM/exportDOM |
$getDocument() |
Returns the document that owns the editor root (falls back to globalThis.document) |
document elsewhere |
getRootOwnerDocument(rootElement) or element.ownerDocument |
Returns the document that owns a specific element |
document.activeElement |
getActiveElement(element) / getActiveElementDeep(document) |
Walks through shadow roots to find the real focused element |
event.target |
getComposedEventTarget(event) |
Returns the un-retargeted target for composed events |
element.parentElement |
getParentElement(element) |
Crosses shadow boundaries correctly |
selection.anchorNode / focusNode |
getDOMSelectionPoints(selection, rootElement) |
Returns un-retargeted boundary points |
Two ESLint rules enforce the globals rows at lint time:
no-restricted-syntax(error) catches alldocument.*andwindow.*member access in library sources.@lexical/no-document-in-dom-methods(error, with autofix) catchesdocument.*specifically insidecreateDOM/updateDOM/exportDOMmethods and autofixes to$getDocument().*.
The remaining rows involve local variable properties that lint cannot reliably detect, so they must be caught in code review.
For full details on the browser platform APIs involved, see Shadow DOM and iframes.
Every commit message — not just PR bodies — must be written to the shape of
.github/pull_request_template.md, so any commit can seed a PR directly
without being rewritten. This applies to every commit you author, including
one-line fixes; do not wait to be asked. Read the template rather than working
from memory, and fill in its sections:
- Subject line:
[Affected Packages] PR Type: title, where the packages are the directory names underpackages/that the diff touches and the type is one of Breaking change / Refactor / Feature / Bug Fix / Documentation Update / Chore. Test-only and tooling changes areChore. ## Description: what the current behavior is and what this change makes it do. AddCloses #<issue>only when there is a real issue number; drop the line otherwise rather than leaving the template's placeholder behind.## Test planwith### Beforeand### Aftersubsections: the command you ran, plus the actual failing output before and passing output after. Paste real output — do not describe it. If a platform or browser in the matrix could not be exercised, say so explicitly under### After.
Drop any template section that does not apply to the diff instead of carrying an empty heading, and treat the template's HTML comments as instructions to follow, not text to copy into the message.
This is an open source project: never include agent-session URLs or other
private/team-internal links (e.g. https://claude.ai/code/session_...) in
commit messages, PR titles, or PR bodies. Those URLs are private to the
person or team that ran the session and are meaningless or misleading to
everyone else. Co-authorship attribution (e.g. Co-Authored-By:) is fine.
For Claude Code this is enforced mechanically via attribution.sessionUrl: false in the checked-in .claude/settings.json; agents from other vendors
should follow this rule as written.
- Uses Rollup for bundling
- Build script:
scripts/build.mjs - Supports multiple build modes: development, production, www (Meta internal)
- TypeScript source → compiled to ESM for npm (
dist/*.jsin the"type": "module"packages; CommonJS consumers load it throughrequire(esm), so the fork modules must stay free of top-level await) and to CommonJS for www only (--www) - Package manager logic in
scripts/shared/packagesManager.mjs