# 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