{"owner":"facebook","repo":"lexical","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md"],"files":{"AGENTS.md":"# Lexical Agent Guide\n\nThis file provides detailed guidance for AI agents and automated tools working with the Lexical codebase.\n\n## Build, Test, and Development Commands\n\n### Building\n- `pnpm run build` - Build all packages in development mode\n- `pnpm run build-prod` - Clean and build all packages in production mode\n- `pnpm run build-release` - Build production release with error codes\n- `pnpm run build-types` - Build TypeScript type definitions and validate them\n\n### Testing\n- `pnpm run test-unit` - Run all unit tests (Vitest, jsdom)\n- `pnpm run test-unit-watch` - Run unit tests in watch mode\n- `pnpm run test-browser` - Run browser-mode unit tests (Vitest + Playwright, real browser)\n- `pnpm run test-browser-watch` - Run browser-mode tests in watch mode\n- `pnpm run test-e2e-chromium` - Run E2E tests in Chromium (requires dev server running)\n- `pnpm run test-e2e-firefox` - Run E2E tests in Firefox\n- `pnpm run test-e2e-webkit` - Run E2E tests in WebKit\n- `pnpm run debug-test-e2e-chromium` - Run E2E tests in debug mode (headed)\n- `pnpm run debug-test-unit` - Debug unit tests with inspector\n\nFor E2E testing workflow:\n1. Start the dev server: `pnpm run start` (or `pnpm run dev` if you don't need collab)\n2. In another terminal: `pnpm run test-e2e-chromium`\n\n### Development Servers\n- `pnpm run start` - Start playground dev server + collab server (http://localhost:3000)\n- `pnpm run dev` - Start only the playground dev server (no collab)\n- `pnpm run start:website` - Start Docusaurus website (http://localhost:3001)\n- `pnpm run collab` - Start collab server on localhost:1234\n\n### Code Quality\n- `pnpm run lint` - Run ESLint on all files\n- `pnpm run lint:fix` - Auto-fix lint issues\n- `pnpm run prettier` - Check code formatting\n- `pnpm run prettier:fix` - Auto-fix formatting issues\n- `pnpm run flow` - Run Flow type checker\n- `pnpm run tsc` - Run TypeScript compiler\n- `pnpm run ci-check` - Run all checks (TypeScript, Flow, Prettier, ESLint)\n\n**Never commit changes to `scripts/error-codes/codes.json`.**\nThat edit is not yours to make — revert it to the state of\n`main` before staging, and never `git add` the file.\n\n### Searching and refactoring\n\nPrefer **ast-grep** over line-oriented regex (`grep`/`sed`) for anything\nstructural — matching or rewriting imports, call sites, JSX, type\nannotations, etc. Regexes miss multi-line forms (a symbol on its own line\ninside a multi-line `import { ... }` block) and produce false positives\n(`IS_APPLE` matching inside `IS_APPLE_WEBKIT`); ast-grep matches the syntax\ntree, so it does neither.\n\nIt isn't a dependency, so run it via npx (the CLI binary is `ast-grep`, not\nthe shadow-utils `sg` that may be on `PATH`):\n\n```sh\n# Find every import of something from '@lexical/utils' (ts and tsx are\n# separate grammars, so pass -l for each; add --json=compact to post-process)\nnpx --package @ast-grep/cli ast-grep run \\\n  -p \"import { \\$\\$\\$NAMES } from '@lexical/utils'\" -l ts packages\n```\n\nMetavariables (`$NAME`, `$$$LIST`) capture nodes for reporting or rewriting\nwith `--rewrite`. Reach for it whenever a change spans many files and must be\nprecise — e.g. moving symbols that `@lexical/utils` merely re-exports back to\na direct `lexical` import.\n\n### Tree-shaking annotations\n\nModule-scope calls to the side-effect-free factories (`defineExtension`,\n`configExtension`, `safeCast`, `createCommand`, `createState`,\n`defineImportRule`, etc.) must be annotated with `/* @__PURE__ */` so\nbundlers can drop unused definitions from application bundles. This is\nenforced (with an autofixer) by the\n`@lexical/internal/require-pure-annotation` ESLint rule — run\n`pnpm run lint:fix` (also run by the pre-commit hook) to insert the\nannotations automatically. When adding a new factory of this kind,\nannotate its definition with `@__NO_SIDE_EFFECTS__` and add its name to\nthe rule's default list in\n`packages/lexical-eslint-plugin-internal/src/rules/require-pure-annotation.js`.\n\n## High-Level Architecture\n\n### Core Concepts\n\nLexical is built around several key architectural concepts that work together:\n\n**Editor Instance** - Created via `createEditor()`, wires everything together. Manages the EditorState, registers listeners/commands/transforms, and handles DOM reconciliation.\n\n**EditorState** - Immutable data model representing the editor content. Contains:\n- A node tree (hierarchical structure of LexicalNodes)\n- A selection object (current cursor/selection state)\n- Fully serializable to/from JSON\n\n**`$` Functions Convention** - Functions prefixed with `$` (e.g., `$getRoot()`, `$getSelection()`) can ONLY be called within:\n- `editor.update(() => {...})` - for mutations\n- `editor.read(() => {...})` - for read-only access\n- Node transforms and command handlers (which have implicit update context)\n\nThis is similar to React hooks' restrictions but enforces synchronous context instead of call order.\n\n**Double-Buffering Updates** - When `editor.update()` is called:\n1. Current EditorState is cloned as work-in-progress\n2. Mutations modify the work-in-progress state\n3. Multiple synchronous updates are batched\n4. DOM reconciler diffs and applies changes\n5. New immutable EditorState becomes current\n\n**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.\n\n### Monorepo Structure\n\nThis is a monorepo with packages in `packages/`:\n\n**Core Packages:**\n- `lexical` - Core framework (Editor, EditorState, base nodes, selection, updates)\n- `@lexical/react` - React bindings (LexicalComposer, plugins as components)\n- `@lexical/headless` - Headless editor for server-side/testing\n\n**Feature Packages** (extend core with nodes/commands/utilities):\n- `@lexical/rich-text` - Rich text editing (headings, quotes, etc.)\n- `@lexical/plain-text` - Plain text editing\n- `@lexical/extension` - Extend editor functionality\n- `@lexical/list` - List nodes (ordered/unordered/checklist)\n- `@lexical/table` - Table support\n- `@lexical/code` - Code block with syntax highlighting\n- `@lexical/link` - Link nodes and utilities\n- `@lexical/markdown` - Markdown import/export\n- `@lexical/html` - HTML serialization\n- `@lexical/history` - Undo/redo\n- `@lexical/yjs` - Real-time collaboration via Yjs\n- And many more...\n\n**Development Packages:**\n- `lexical-playground` - Full-featured demo application\n- `lexical-website` - Docusaurus documentation site\n\n### Key Architectural Patterns\n\n**Extensions** - Extensions should be used to add features and configuration\nto an editor. The set of extensions in an editor must be determined when the\neditor is created with `buildEditorFromExtensions`. Extensions with\nfunctionality that can be toggled on or off typically have a `disabled`\nconfiguration property and output signal that defaults to `false`. See the\nlexical-extension package and the supporting code in lexical for\nmore examples and implementation details.\n\n```tsx\nexport interface MyConfig {\n  disabled: boolean;\n}\nexport const MyExtension = defineExtension({\n  build: (_editor, config, _state) => namedSignals(config),\n  config: safeCast<MyConfig>({ disabled: false }),\n  name: '@lexical/docs/My',\n  nodes: () => [MyNode],\n  register: (editor, _config, state) => {\n    const {disabled} = state.getOutput();\n    return effect(() => {\n      if (!disabled.value) {\n        return editor.registerUpdateListener(({editorState}) => {\n          // React to updates\n        });\n      }\n    })\n  },\n})\n```\n\n**Plugin System (React)** - Plugins are a legacy pattern for React components\nto hook into the editor lifecycle, extensions should be preferred for new code:\n```jsx\nfunction MyPlugin() {\n  const [editor] = useLexicalComposerContext();\n  useEffect(() => {\n    return editor.registerUpdateListener(({editorState}) => {\n      // React to updates\n    });\n  }, [editor]);\n  return null;\n}\n```\n\n**Command Pattern** - Commands are the primary communication mechanism:\n- Create with `createCommand()`\n- Dispatch with `editor.dispatchCommand(command, payload)`\n- Handle with `editor.registerCommand(command, handler, priority)`\n- Handlers propagate by priority until one stops propagation\n\n**Node Transforms** - Registered via `editor.registerNodeTransform(NodeClass, transform)`. Called automatically during updates when nodes of that type change. Have implicit update context.\n\n**Listeners** - All `editor.register*()` methods return cleanup functions for easy unsubscription.\n\n## Type System\n\nThis codebase uses **both TypeScript and Flow**:\n- Source files are primarily TypeScript (`.ts`, `.tsx`)\n- Flow type definitions are generated in `packages/*/flow/` directories\n- Run `pnpm run flow` to check Flow types\n- Run `pnpm run tsc` to check TypeScript types\n- Both are checked in CI via `pnpm run ci-check`\n\nWhen adding/modifying APIs, types must be maintained for both systems.\n\n## Backwards Compatibility\n\n**All changes MUST be backwards compatible.** Lexical is a widely-adopted OSS library, and breaking changes ripple out to every downstream consumer.\n\n- Do NOT remove or rename existing public APIs, exported functions, types, or `$` functions. Add new APIs alongside the old ones instead.\n- Do NOT change the signature, return type, or behavior of existing public APIs in ways that could break callers. Prefer additive, optional parameters.\n- Preserve the serialization format of `EditorState` and node JSON. Serialized content produced by older versions must continue to deserialize correctly.\n- If an API genuinely must change, deprecate the old one first (keep it working, document the replacement) rather than removing it outright.\n- When in doubt, assume external code depends on the current behavior and keep it intact.\n\n## Important Development Notes\n\n### Reconciliation and Updates\n- `editor.read(...)`  or `editor.read('force-commit', ...)` flushes pending updates first, then provides consistent reconciled state\n- `editor.read('pending', ...)` reads the pending state (like `editor.update(...)`, but read-only)\n- `editor.read('latest', ...)` reads the latest consistent reconciled state\n- Inside `editor.update()`, you see pending state (transforms/reconciliation not yet run)\n- `editor.getEditorState().read()` always uses latest reconciled state, but prefer `editor.read('latest', ...)` in new code\n- Updates can be nested: `editor.update(() => editor.update(...))` is allowed but strongly discouraged\n- Do NOT nest updates in reads, or use a force-commit in an update\n\n### Node References\nAlways 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.\n\n### Testing Strategy\n- **Unit tests** - Vitest (jsdom), located in `packages/**/__tests__/unit/**/*.test.{ts,tsx}`\n- **Browser tests** - Vitest browser mode driven by the Playwright runner, located in\n  `packages/**/__tests__/browser/**/*.test.{ts,tsx}`. Use these for behavior that depends on\n  a real layout/selection engine instead of stubbing the missing jsdom functionality from\n  `vitest.setup.mts` (e.g. `Range.getBoundingClientRect`, the Selection API). Run with\n  `pnpm run test-browser`; the browser set is controlled by the `VITEST_BROWSER` env var\n  (comma-separated, default `chromium`). Prefer building editors with the extension APIs\n  (`buildEditorFromExtensions`, or `LexicalExtensionComposer`/`LexicalExtensionEditorComposer`\n  in React).\n  - **Do not use `using`/`Disposable` in browser tests (or any browser-facing code).**\n    Explicit Resource Management (`using`, `Symbol.dispose`, `Disposable`) is not supported\n    in WebKit/Safari yet, so the syntax throws a `SyntaxError` there. `using` is fine in unit\n    tests (jsdom/Node), but browser tests should clean up with\n    `onTestFinished(() => editor.dispose())` instead — `editor.dispose()` is a plain method\n    available on the result of `buildEditorFromExtensions`.\n- **E2E tests** - Playwright, located in `packages/lexical-playground/__tests__/e2e/**/*.spec.{ts,mjs}`\n- E2E tests require the playground dev server running\n- Use `pnpm run debug-test-e2e-chromium` to debug E2E tests with browser UI\n\n### Custom Nodes\nWhen creating custom nodes:\n1. Extend a base node class (TextNode, ElementNode, DecoratorNode)\n2. Implement instance methods: `$config()`, `createDOM()`, `updateDOM()`\n3. Register with extension or editor config: `nodes: [YourCustomNode]`\n4. Export a `$createYourNode()` factory function (follows $ convention)\n\n### Shadow DOM and iframe realm safety\n\nLexical supports editors whose root element lives inside a Shadow DOM or an\n`<iframe>` document. The editor resolves its `window` and `document` from\n`rootElement.ownerDocument.defaultView`, so code that reaches for the **global**\n`window` or `document` will silently use the wrong realm when the editor crosses\na frame boundary. Shadow DOM adds a second hazard: the browser **retargets**\nselection and focus reads to the shadow host, hiding the real nodes.\n\nUse the shadow/iframe-aware helpers exported from `lexical` instead of the raw\nbrowser globals:\n\n| Instead of | Use | Why |\n| --- | --- | --- |\n| `window` | `element.ownerDocument.defaultView` or `getDefaultView(element)` (@internal) | Returns the window that owns the element |\n| `window.getSelection()` | `getDOMSelection(rootElement.ownerDocument.defaultView)` | Reads selection from the correct window |\n| `selection.getRangeAt(0)` | `getDOMSelectionRange(selection, rootElement)` | Unwraps retargeted shadow-DOM selection |\n| `document` in `createDOM`/`updateDOM`/`exportDOM` | `$getDocument()` | Returns the document that owns the editor root (falls back to `globalThis.document`) |\n| `document` elsewhere | `getRootOwnerDocument(rootElement)` or `element.ownerDocument` | Returns the document that owns a specific element |\n| `document.activeElement` | `getActiveElement(element)` / `getActiveElementDeep(document)` | Walks through shadow roots to find the real focused element |\n| `event.target` | `getComposedEventTarget(event)` | Returns the un-retargeted target for composed events |\n| `element.parentElement` | `getParentElement(element)` | Crosses shadow boundaries correctly |\n| `selection.anchorNode` / `focusNode` | `getDOMSelectionPoints(selection, rootElement)` | Returns un-retargeted boundary points |\n\nTwo ESLint rules enforce the globals rows at lint time:\n- `no-restricted-syntax` (error) catches all `document.*` and `window.*` member access in library sources.\n- `@lexical/no-document-in-dom-methods` (error, with autofix) catches `document.*` specifically inside `createDOM`/`updateDOM`/`exportDOM` methods and autofixes to `$getDocument().*`.\n\nThe remaining rows involve local variable properties that lint cannot reliably detect, so they must be caught in code review.\n\nFor full details on the browser platform APIs involved, see\n[Shadow DOM and iframes](packages/lexical-website/docs/concepts/shadow-dom.md).\n\n### Commits and Pull Requests\nEvery commit message — not just PR bodies — must be written to the shape of\n`.github/pull_request_template.md`, so any commit can seed a PR directly\nwithout being rewritten. This applies to every commit you author, including\none-line fixes; do not wait to be asked. Read the template rather than working\nfrom memory, and fill in its sections:\n\n- **Subject line**: `[Affected Packages] PR Type: title`, where the packages are\n  the directory names under `packages/` that the diff touches and the type is\n  one of Breaking change / Refactor / Feature / Bug Fix / Documentation Update /\n  Chore. Test-only and tooling changes are `Chore`.\n- **`## Description`**: what the current behavior is and what this change makes\n  it do. Add `Closes #<issue>` only when there is a real issue number; drop the\n  line otherwise rather than leaving the template's placeholder behind.\n- **`## Test plan`** with `### Before` and `### After` subsections: the command\n  you ran, plus the actual failing output before and passing output after.\n  Paste real output — do not describe it. If a platform or browser in the\n  matrix could not be exercised, say so explicitly under `### After`.\n\nDrop any template section that does not apply to the diff instead of carrying\nan empty heading, and treat the template's HTML comments as instructions to\nfollow, not text to copy into the message.\n\n### Commit and PR Hygiene for Agents\nThis is an open source project: never include agent-session URLs or other\nprivate/team-internal links (e.g. `https://claude.ai/code/session_...`) in\ncommit messages, PR titles, or PR bodies. Those URLs are private to the\nperson or team that ran the session and are meaningless or misleading to\neveryone else. Co-authorship attribution (e.g. `Co-Authored-By:`) is fine.\nFor Claude Code this is enforced mechanically via `attribution.sessionUrl:\nfalse` in the checked-in `.claude/settings.json`; agents from other vendors\nshould follow this rule as written.\n\n### Build System\n- Uses Rollup for bundling\n- Build script: `scripts/build.mjs`\n- Supports multiple build modes: development, production, www (Meta internal)\n- TypeScript source → compiled to CommonJS and ESM\n- Package manager logic in `scripts/shared/packagesManager.mjs`\n"}}