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, 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 Flow type checkerpnpm run tsc- Run TypeScript compilerpnpm run ci-check- Run all checks (TypeScript, Flow, Prettier, ESLint)
Module-scope calls to the side-effect-free factories (defineExtension,
configExtension, safeCast, createCommand, createState,
defineImportRule, etc.) must be annotated with /* @__PURE__ */ so
bundlers can drop unused definitions from application bundles. This is
enforced (with an autofixer) by the
@lexical/internal/require-pure-annotation ESLint rule — run
pnpm run lint:fix (also run by the pre-commit hook) to insert the
annotations automatically. When adding a new factory of this kind,
annotate its definition with @__NO_SIDE_EFFECTS__ and add its name to
the rule's default list in
packages/lexical-eslint-plugin-internal/src/rules/require-pure-annotation.js.
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.
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 (jsdom), located in
packages/**/__tests__/unit/**/*.test.{ts,tsx} - 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)
- Implement instance methods:
$config(),createDOM(),updateDOM() - Register with extension or editor config:
nodes: [YourCustomNode] - Export a
$createYourNode()factory function (follows $ convention)
- Uses Rollup for bundling
- Build script:
scripts/build.mjs - Supports multiple build modes: development, production, www (Meta internal)
- TypeScript source → compiled to CommonJS and ESM
- Package manager logic in
scripts/shared/packagesManager.mjs