# Lexical Agent Guide This file provides detailed guidance for AI agents and automated tools working with the Lexical codebase. ## Build, Test, and Development Commands ### Building - `pnpm run build` - Build all packages in development mode - `pnpm run build-prod` - Clean and build all packages in production mode - `pnpm run build-release` - Build production release with error codes - `pnpm run build-types` - Build TypeScript type definitions and validate them ### Testing - `pnpm run test-unit` - Run all unit tests (Vitest, Node/jsdom) - `pnpm run test-unit-watch` - Run unit tests in watch mode - `pnpm run test-browser` - Run browser-mode unit tests (Vitest + Playwright, real browser) - `pnpm run test-browser-watch` - Run browser-mode tests in watch mode - `pnpm run test-e2e-chromium` - Run E2E tests in Chromium (requires dev server running) - `pnpm run test-e2e-firefox` - Run E2E tests in Firefox - `pnpm run test-e2e-webkit` - Run E2E tests in WebKit - `pnpm 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: 1. Start the dev server: `pnpm run start` (or `pnpm run dev` if you don't need collab) 2. In another terminal: `pnpm run test-e2e-chromium` ### Development Servers - `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 ### Code Quality - `pnpm run lint` - Run ESLint on all files - `pnpm run lint:fix` - Auto-fix lint issues - `pnpm run prettier` - Check code formatting - `pnpm run prettier:fix` - Auto-fix formatting issues - `pnpm run flow` - Run a full foreground Flow check (does not reuse a server) - `pnpm run tsc` - Run TypeScript compiler - `pnpm 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. ### Searching and refactoring 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`): ```sh # 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 packages ``` Metavariables (`$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. ### Extension source imports 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. ### Tree-shaking annotations 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
` 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. ## High-Level Architecture ### Core Concepts 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 mutations - `editor.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: 1. Current EditorState is cloned as work-in-progress 2. Mutations modify the work-in-progress state 3. Multiple synchronous updates are batched 4. DOM reconciler diffs and applies changes 5. 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. ### Monorepo Structure 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 application - `lexical-website` - Docusaurus documentation site ### Key Architectural Patterns **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. ```tsx export interface MyConfig { disabled: boolean; } export const MyExtension = defineExtension({ build: (_editor, config, _state) => namedSignals(config), config: safeCast({ 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: ```jsx 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. ## Type System 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 flow` to check Flow types - Run `pnpm run tsc` to check TypeScript types - Both are checked in CI via `pnpm run ci-check` When adding/modifying APIs, types must be maintained for both systems. ## Backwards Compatibility **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 `EditorState` and 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. ## Dependencies: Lexical is a singleton within one app 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, `EditorState`s, 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 publish` rewrites that to the exact published version, so every `@lexical/*` release pins the matching `lexical`. 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 `lexical` or `@lexical/*`) declares those packages in `peerDependencies`, plus `devDependencies` for its own build and tests, never in `dependencies`. Apps bring their own Lexical and own the version. - Code under `examples/` and `packages/lexical-playground` are 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. ## Important Development Notes ### Reconciliation and Updates - `editor.read(...)` or `editor.read('force-commit', ...)` flushes pending updates first, then provides consistent reconciled state - `editor.read('pending', ...)` reads the pending state (like `editor.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 prefer `editor.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 ### Node References 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. ### Testing Strategy - **Unit tests** - Vitest, located in `packages/**/__tests__/unit/**/*.test.{ts,tsx}`. Add `// @vitest-environment node` after the copyright header for tests that only exercise editor state, headless behavior, or pure utilities. DOM tests use jsdom with `vmThreads` on 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's `require(esm)` dependencies. Platform-mocked tests depend on module isolation. Compiler tests run with `scripts-unit` in ordinary Node workers because native build tools are not VM-realm-safe. Use `DOMParser` to 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 from `vitest.setup.mts` (e.g. `Range.getBoundingClientRect`, the Selection API). Run with `pnpm run test-browser`; the browser set is controlled by the `VITEST_BROWSER` env var (comma-separated, default `chromium`). Prefer building editors with the extension APIs (`buildEditorFromExtensions`, or `LexicalExtensionComposer`/`LexicalExtensionEditorComposer` in React). - **Do not use `using`/`Disposable` in 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 a `SyntaxError` there. `using` is fine in unit tests (jsdom/Node), but browser tests should clean up with `onTestFinished(() => editor.dispose())` instead — `editor.dispose()` is a plain method available on the result of `buildEditorFromExtensions`. - **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-chromium` to debug E2E tests with browser UI ### Custom Nodes When creating custom nodes: 1. Extend a base node class (TextNode, ElementNode, DecoratorNode). The constructor must work with no arguments. 2. Implement `$config()` with `this.config('type', {extends: BaseNode, json: schema})`, where `schema` is built with `nodeSchema()({...})` as a module-scope `const` above 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 with `withField(stringValue(), {field: '__name'})`. Initialize that field in the class (`__name: string = '';`) so it is an own property of a fresh node. Use `withAccessors` only where a property is not a plain field. 3. Do not hand-write `clone`, `importJSON`, `exportJSON` or `updateFromJSON`: `$config` derives them from the schema, and it carries every property declared with `withField` across 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 with `withField` rather than writing `afterCloneFrom`. Ad-hoc data that is not inherent to the node type belongs in NodeState (`createState`), which clones on its own. Write `afterCloneFrom` only 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'}` in `MarkNode`). Call `super.afterCloneFrom(prevNode)` first, and copy every field of the class, schema fields included: Lexical does not add to a class's own `afterCloneFrom`. 4. Implement `createDOM()` and `updateDOM()` (and `decorate()` for a DecoratorNode). Keep `exportDOM()` on the class when the default export is not enough. 5. For HTML import, prefer `defineImportRule` rules registered by the extension that provides the node: list `CoreImportExtension` and `configExtension(DOMImportExtension, {rules: [...]})` in its `dependencies` (see `ListExtension`, and `ListImportRules` for the rules, in `@lexical/list`). Both APIs are `@experimental`, and the rules only apply where the editor routes HTML through the pipeline; see `packages/lexical-website/docs/serialization/dom-import.md`. `importDOM()`, which that page calls legacy, is not deprecated yet, but its deprecation is planned. 6. Register with an extension (`nodes: () => [YourNode]`) or editor config. 7. 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`. ### Shadow DOM and iframe realm safety Lexical supports editors whose root element lives inside a Shadow DOM or an `