Rich Text Editor Testing Guide
How to Test Lexical Editor with Playwright: Complete 2026 Guide
A scenario-by-scenario walkthrough of testing Lexical rich text editors with Playwright. EditorState reconciliation, custom node selection, decorator components, collaboration plugins, command dispatching, and the structural pitfalls that silently break real editor test suites.
“Lexical powers the editor experience across Meta products including Facebook, Instagram, and Workplace, serving hundreds of millions of users who compose rich text content daily.”
Meta Open Source
Lexical Editor Update Cycle
1. Why Testing Lexical Editors Is Harder Than It Looks
Lexical is Meta's extensible text editor framework, built to replace Draft.js with a fundamentally different architecture. Unlike traditional contentEditable wrappers that treat the DOM as the source of truth, Lexical maintains its own internal EditorState tree. Every user action first mutates a cloned EditorState, then a reconciler diffs the old and new states and patches the DOM to match. This means the DOM you see in the browser is a derived artifact, not the canonical data. Testing against DOM selectors alone can give you false positives because the DOM may look correct while the EditorState is corrupted, or false negatives because the reconciler batches updates and the DOM temporarily lags behind user input.
The complexity multiplies across six structural dimensions. First, Lexical's selection model is its own abstraction (RangeSelection, NodeSelection, GridSelection) that does not map one to one with the browser's native Selection API. Your Playwright test may set a DOM selection that Lexical silently discards during the next reconciliation pass. Second, custom nodes (mentions, embeds, code blocks, equations) each have their own serialization logic, and a test that only checks visual output will miss serialization bugs that corrupt data on save. Third, decorator nodes render actual React components inside the editor, creating a mixed rendering tree where some content is Lexical-managed DOM and some is React-managed DOM. Fourth, the collaboration plugin (built on Yjs) introduces conflict resolution, cursor awareness, and eventual consistency that make assertions timing-dependent. Fifth, Lexical uses a command dispatching system where plugins register listeners for typed commands, and the execution order of those listeners affects the editor output. Sixth, node transforms can mutate the EditorState after your action but before the DOM updates, creating a race between your Playwright assertion and Lexical's internal pipeline.
Lexical Internal Architecture
User Action
Keystroke, paste, drop
Command Dispatch
Typed command bus
EditorState Clone
Immutable snapshot
Node Transforms
Plugin mutations
Reconciler Diff
Old vs new state
DOM Patch
Minimal mutations
Decorator Node Rendering Pipeline
EditorState
DecoratorNode in tree
Reconciler
Creates placeholder DOM
React Portal
Mounts component into placeholder
Mixed DOM
Lexical + React coexist
A reliable Lexical test suite must validate at two levels: the DOM output that users see, and the EditorState that gets serialized and persisted. The sections below walk through each scenario with runnable Playwright TypeScript that addresses both layers.
2. Setting Up a Reliable Test Environment
Before writing any scenarios, establish an environment that gives your Playwright tests access to both the rendered editor and its internal EditorState. Lexical exposes the editor instance through a React ref or a global debug hook. For testing, the cleanest approach is to expose a helper on window in your test build that lets Playwright read and manipulate the EditorState directly.
Lexical Test Environment Checklist
- Install @lexical/react, @lexical/rich-text, @lexical/list, @lexical/code, and @lexical/link
- Create a test page route that renders the editor with all plugins enabled
- Expose window.__lexicalEditor in the test build for EditorState inspection
- Register all custom nodes in the editor config (MentionNode, ImageNode, etc.)
- Configure Playwright actionTimeout to 10s to account for reconciler batching
- Add a data-testid on the contentEditable root for reliable selection
- Set up serialization helpers to export EditorState as JSON for assertions
- For collaboration tests, run a local WebSocket server (y-websocket)
Exposing the Editor Instance for Testing
Environment Variables
Playwright Configuration
3. Scenario: Basic Text Input and Formatting
The first scenario every Lexical test suite needs is basic text input followed by inline formatting. This verifies that the editor accepts keystrokes, the reconciler renders them to the DOM, and format commands (bold, italic, underline) produce the correct EditorState structure. The critical subtlety is that Playwright's type() method fires synthetic keyboard events, and Lexical processes those through its own input handling pipeline. You cannot use fill()because Lexical's contentEditable does not respond to the value property the way a standard input element does.
Basic Text Input and Bold Formatting
StraightforwardGoal
Type a paragraph into the Lexical editor, apply bold formatting to a selected word, and verify both the DOM output and the serialized EditorState contain the correct structure.
Preconditions
- Editor rendered at
EDITOR_TEST_ROUTEwith RichTextPlugin enabled window.__lexicalEditorexposed via TestEditorPlugin- Editor starts with empty content
Playwright Implementation
What to Assert Beyond the UI
- The EditorState JSON has exactly one ParagraphNode with the correct children count
- The bold text node uses format bitmask
1(not a CSS class hack) - Calling
editor.getEditorState().toJSON()produces valid JSON that can be deserialized back without errors
Basic Typing: Playwright vs Assrt
import { test, expect } from '@playwright/test';
test('type text and apply bold', async ({ page }) => {
await page.goto('/test/editor');
const editor = page.locator('[data-testid="lexical-editor"] [contenteditable="true"]');
await editor.click();
await editor.pressSequentially('The quick brown fox');
const textNode = editor.locator('p').first();
const box = await textNode.boundingBox();
await page.mouse.dblclick(box!.x + 55, box!.y + box!.height / 2);
await page.keyboard.press('Meta+b');
await expect(editor.locator('strong')).toHaveText('quick');
const stateJSON = await page.evaluate(() =>
(window as any).__getEditorStateJSON()
);
const boldNode = stateJSON.root.children[0].children.find(
(c: any) => c.format === 1
);
expect(boldNode).toBeDefined();
expect(boldNode.text).toBe('quick');
});4. Scenario: Custom Node Creation and Serialization
Lexical's plugin architecture encourages developers to create custom nodes for domain-specific content: mentions, hashtags, embeds, equations, collapsible sections, and more. Each custom node class must implement exportJSON() and importJSON() for persistence, createDOM() and updateDOM() for rendering, and optionally exportDOM() for HTML serialization. A test that only checks the visual output will miss bugs in the serialization path, which means content looks correct in the editor but corrupts when saved to your backend and reloaded.
The mention node is the canonical example. When a user types @ followed by a name, an autocomplete dropdown appears, the user selects a result, and Lexical inserts a MentionNode that carries both display text and a user ID. Your test must verify the trigger character, the dropdown behavior, the node insertion, the DOM representation, and the serialized JSON output.
Mention Node: Trigger, Selection, and Serialization
ModerateGoal
Trigger the mention autocomplete by typing @, select a user from the dropdown, and verify the MentionNode is correctly serialized with its user ID and display name.
Playwright Implementation
Testing exportDOM() for HTML Serialization
5. Scenario: Decorator Nodes (Embedded React Components)
Decorator nodes are Lexical's mechanism for embedding arbitrary React components inside the editor content. Unlike regular Lexical nodes where createDOM() returns a plain DOM element, decorator nodes return a React element from their decorate() method. Lexical's reconciler creates a placeholder <span> in the DOM, and a separate React portal mounts the component into that placeholder. This creates a dual rendering pipeline: Lexical manages the editor tree, React manages the component tree inside each decorator placeholder.
For testing, this dual pipeline means you must wait for both Lexical's reconciliation and React's render cycle to complete. A Playwright assertion that runs immediately after inserting a decorator node may find the placeholder <span>but not the React component inside it. You need explicit waits for the decorator's rendered content, not just the Lexical DOM node.
Image Decorator Node: Insert, Resize, and Persist
ComplexGoal
Insert an image decorator node via the toolbar, verify the React component renders inside the editor, interact with the resize handles, and confirm the updated dimensions persist in the EditorState.
Playwright Implementation
Image Decorator: Playwright vs Assrt
import { test, expect } from '@playwright/test';
test('image decorator node', async ({ page }) => {
await page.goto('/test/editor');
const editor = page.locator('[data-testid="lexical-editor"] [contenteditable="true"]');
await editor.click();
await page.locator('[data-testid="toolbar-insert-image"]').click();
const modal = page.locator('[data-testid="image-insert-modal"]');
await modal.locator('input[name="src"]').fill('https://picsum.photos/800/600');
await modal.locator('input[name="alt"]').fill('Test landscape image');
await modal.locator('button[type="submit"]').click();
const imageWrapper = editor.locator('[data-testid="image-decorator"]');
await expect(imageWrapper).toBeVisible({ timeout: 10_000 });
const resizeHandle = imageWrapper.locator('[data-testid="resize-handle-se"]');
const box = await resizeHandle.boundingBox();
await page.mouse.down();
await page.mouse.move(box!.x - 100, box!.y - 75, { steps: 10 });
await page.mouse.up();
const stateJSON = await page.evaluate(() =>
(window as any).__getEditorStateJSON()
);
const imgNode = stateJSON.root.children
.flatMap((p: any) => p.children || [p])
.find((n: any) => n.type === 'image');
expect(imgNode.width).toBeLessThan(800);
});6. Scenario: Command Dispatching and Listeners
Lexical uses a typed command system for inter-plugin communication. When you press Cmd+B, Lexical dispatches a FORMAT_TEXT_COMMAND with payload "bold". Any plugin can register a listener for any command, and listeners execute in priority order. A listener can return true to stop propagation, meaning downstream listeners never fire. This architecture makes Lexical extremely extensible, but it also means that a plugin registering a higher-priority listener can silently intercept commands your test expects to reach the default handler.
Testing command dispatching requires verifying two things: that the command reaches the intended listener, and that the EditorState reflects the expected mutation. You should test both the keyboard shortcut path (which triggers the command through Lexical's input pipeline) and the direct dispatch path (which calls editor.dispatchCommand() programmatically).
Custom Command: Insert Horizontal Rule
ModerateGoal
Dispatch a custom INSERT_HORIZONTAL_RULE_COMMAND from both a toolbar button and a keyboard shortcut, verify the HorizontalRuleNode appears in the EditorState, and confirm the DOM renders an <hr> element.
Playwright Implementation
7. Scenario: Collaboration Plugin (Yjs Integration)
Lexical's collaboration plugin uses Yjs, a CRDT library, to synchronize EditorState across multiple clients in real time. Each client maintains a local Yjs document that merges with remote updates through a WebSocket provider. Testing collaboration introduces three challenges that single-editor tests never face. First, you need two independent browser contexts connected to the same Yjs room. Second, you must handle eventual consistency: when Client A types a character, Client B may not see it for several milliseconds depending on network latency and Yjs merge timing. Third, conflict resolution can reorder or transform content, meaning Client B's DOM may not match Client A's DOM character for character during concurrent edits.
Two-Client Collaboration: Type, Sync, and Verify
ComplexGoal
Open two browser contexts connected to the same collaboration room, type text in each, and verify both editors converge to the same content.
Preconditions
- A y-websocket server running at
ws://localhost:1234 - The editor configured with CollaborationPlugin pointing to the same room name
- Both contexts using the same Yjs document ID
Playwright Implementation
Collaboration Sync: Playwright vs Assrt
import { test, expect } from '@playwright/test';
test('two clients converge', async ({ browser }) => {
const roomId = `test-room-${Date.now()}`;
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
await pageA.goto(`/test/editor?room=${roomId}`);
await pageB.goto(`/test/editor?room=${roomId}`);
const editorA = pageA.locator('[contenteditable="true"]');
const editorB = pageB.locator('[contenteditable="true"]');
await expect(pageA.locator('[data-testid="collab-status-connected"]'))
.toBeVisible();
await expect(pageB.locator('[data-testid="collab-status-connected"]'))
.toBeVisible();
await editorA.click();
await editorA.pressSequentially('Hello from A');
await expect(editorB).toContainText('Hello from A', { timeout: 10_000 });
await editorB.click();
await editorB.press('End');
await editorB.press('Enter');
await editorB.pressSequentially('Reply from B');
await expect(editorA).toContainText('Reply from B', { timeout: 10_000 });
const textA = await editorA.textContent();
const textB = await editorB.textContent();
expect(textA).toBe(textB);
await ctxA.close();
await ctxB.close();
});8. Scenario: Selection Model and Range Operations
Lexical's selection model is one of the most counter-intuitive aspects of the framework for testers. The browser maintains its own window.getSelection() state, and Lexical maintains a parallel selection state inside the EditorState. These two can diverge. When Lexical processes an update, it reads the native DOM selection, maps it to its internal node tree, and creates a RangeSelection, NodeSelection, or GridSelectionobject. If the native selection falls on a DOM node that does not map cleanly to a Lexical node (for example, inside a decorator component's React-managed subtree), Lexical may adjust or discard the selection entirely.
For testing, this means you cannot rely solely on Playwright's mouse and keyboard to set a selection and assume Lexical honors it. You must verify the Lexical selection state after every selection operation. The helper below reads Lexical's internal selection and returns it as a serializable object your Playwright assertion can inspect.
Select Across Nodes and Apply Link Formatting
ComplexGoal
Select text that spans two different Lexical nodes (a regular TextNode and a bold TextNode), apply a link, and verify the resulting EditorState has a LinkNode wrapping the correct range.
Playwright Implementation
Cross-Node Selection: Playwright vs Assrt
// Abbreviated for comparison
test('select across nodes and apply link', async ({ page }) => {
await page.goto('/test/editor');
const editor = page.locator('[contenteditable="true"]');
await editor.click();
await editor.pressSequentially('Visit the ');
await page.keyboard.press('Meta+b');
await editor.pressSequentially('official website');
await page.keyboard.press('Meta+b');
await editor.pressSequentially(' for details');
// Manual coordinate-based selection
const pBox = await editor.locator('p').first().boundingBox();
await page.mouse.click(pBox!.x + 45, pBox!.y + pBox!.height / 2);
await page.keyboard.down('Shift');
await page.mouse.click(pBox!.x + 290, pBox!.y + pBox!.height / 2);
await page.keyboard.up('Shift');
// Apply link, verify EditorState
await page.locator('[data-testid="toolbar-insert-link"]').click();
await page.locator('[data-testid="link-url-input"]').fill('https://example.com');
await page.keyboard.press('Enter');
const state = await page.evaluate(() =>
(window as any).__getEditorStateJSON()
);
// ... recursive search for link nodes ...
});9. Common Pitfalls That Break Lexical Test Suites
Using fill() Instead of pressSequentially()
Playwright's fill()method sets the value property of an input element directly. Lexical's contentEditable does not use the value property; it processes individual keyboard events through its input pipeline. If you use fill(), the text may appear in the DOM (browsers sometimes honor value on contentEditable) but Lexical's EditorState will be empty or stale. Always use pressSequentially() or keyboard.type() to fire individual key events that Lexical can process.
Asserting DOM Without Checking EditorState
The DOM is a derived view in Lexical. A node transform can modify content after your action, the reconciler can batch updates, and decorator nodes render asynchronously via React portals. A test that asserts only await expect(editor).toContainText('foo') will pass even if the EditorState is corrupted, the serialization is broken, or the node type is wrong. Always pair DOM assertions with EditorState JSON assertions using the window.__getEditorStateJSON() helper.
Race Conditions with Node Transforms
Node transforms run synchronously during the EditorState update, but their effects may not be visible in the DOM until the next reconciliation tick. If your test types a URL and expects the AutoLinkPlugin to convert it to a LinkNode, you need to wait for the transform to complete before asserting. Use page.waitForFunction() to poll the EditorState until the expected node type appears, rather than using a fixed timeout.
Selection Drift After Reconciliation
Lexical's reconciler can adjust the DOM selection after patching. If your test sets a selection via page.mouse.click() and immediately dispatches a command, the reconciler may have moved the selection by the time the command handler reads it. Always verify the Lexical selection state (not just the native DOM selection) before dispatching commands that depend on cursor position. Read the selection with $getSelection() inside an editor.getEditorState().read() call.
Forgetting to Register Custom Nodes
Lexical requires every node type to be registered in the editor config's nodesarray. If your test page renders the editor without registering your custom MentionNode or ImageNode, Lexical will silently ignore those nodes during deserialization. The editor will appear to work (text nodes still render), but custom nodes will vanish when you reload content from a saved state. Your test environment's editor config must match production exactly.
Lexical Testing Anti-Patterns
- Using fill() on contentEditable (Lexical ignores value property)
- Asserting only DOM without checking EditorState JSON
- Fixed timeouts instead of waitForFunction on EditorState
- Assuming native DOM selection matches Lexical selection
- Missing custom node registration in test editor config
- Testing decorator content before React portal mounts
- Ignoring collaboration merge timing in multi-client tests
- Not cleaning up Yjs rooms between test runs
10. Writing These Scenarios in Plain English with Assrt
Every Lexical scenario above requires deep knowledge of Lexical's internal architecture: the EditorState tree structure, the reconciler timing, the selection model abstraction, and the command dispatching priority system. A single scenario is 30 to 60 lines of Playwright TypeScript. Multiply that by the ten scenarios you need for reasonable coverage and you have a substantial test file that silently breaks the first time a Lexical minor version changes the node serialization format, renames an internal command type, or adjusts reconciler batching behavior.
Assrt lets you describe each scenario in plain English, and the framework generates the equivalent Playwright code with the correct selectors, waits, and EditorState assertions. When Lexical's internal structure changes, Assrt detects the test failure, analyzes the new DOM and EditorState shape, and opens a pull request with updated selectors and assertions. Your scenario files stay untouched.
Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections, committed to your repository as real tests you can read, run, and modify. The collaboration scenario automatically sets up two browser contexts and handles the Yjs synchronization timing. The decorator scenario waits for the React portal to mount before asserting. The selection scenario verifies Lexical's internal selection state, not just the native DOM selection.
Start with the basic typing and formatting scenario. Once it is green in your CI, add the mention node scenario, then the decorator scenario, then the command dispatching test, then the collaboration sync, and finally the cross-node selection test. Within a single afternoon you can have comprehensive Lexical editor coverage that most production applications never achieve by hand.
Related Guides
How to Test CodeMirror
A practical, scenario-by-scenario guide to testing CodeMirror 6 editors with Playwright....
How to Test Monaco Editor
A practical, scenario-by-scenario guide to testing Monaco Editor with Playwright. Covers...
How to Test Notion Onboarding
A practical guide to testing Notion workspace onboarding with Playwright. Covers template...
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.