Skip to content

[Breaking Changes][lexical] Refactor: Port node classes to the $config() protocol#8640

Draft
etrepum wants to merge 1 commit into
facebook:mainfrom
etrepum:claude/festive-euler-fYz9P
Draft

[Breaking Changes][lexical] Refactor: Port node classes to the $config() protocol#8640
etrepum wants to merge 1 commit into
facebook:mainfrom
etrepum:claude/festive-euler-fYz9P

Conversation

@etrepum

@etrepum etrepum commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Breaking Changes

Note that these changes only affect callers of static methods of the built-in node classes. It is still completely supported to implement these static methods on your own node classes, but we of course recommend that you upgrade to the $config protocol as it's more concise and offers more features.

All base classes now use the $config protocol which will widen their types static methods, which are technically public API but were really only intended for use directly by the editor (e.g. ParagraphNode.importDOM() or ParagraphNode.clone() shouldn't appear in user code).

Classes using the $config protocol don't directly implement these static methods, their trivial implementations are generated on first call to getStaticNodeConfig. This happens automatically during editor construction, but may happen earlier.

It's fine to keep calling these static methods directly, no behavior or type change:

  • NodeClass.getType()

You should avoid calling these static methods directly because they are not intended as callable API surface (other than maybe in tests). Their behavior hasn't changed but the types have widened:

  • NodeClass.importJSON() - Use higher-level functions to parse serialized nodes such as LexicalEditor.parseEditorState or $generateNodesFromSerializedNodes
  • NodeClass.importDOM() - You should consider migrating to DOMImportExtension and/or use importDOM indirectly via $generateNodesFromDOM

Refactor direct calls, the implementation is present but the behavior has likely changed:

  • NodeClass.clone() - Use $cloneWithProperties instead. This is present at runtime with a wider type, but only does a trivial construction. The companion afterCloneFrom instance method does all of state propagation.

No longer present on refactored classes, do not call:

  • NodeClass.transform() - This was an @internal API, classes that used these have had their transforms moved to the $config data structure and they are not injected as a static method. It will be deprecated at some point, you can use extensions to register transforms as well as $transform in your $config.

Description

The $config() protocol (added in #8645) lets a node declare its type, cloning, serialization, and DOM-import behavior from a single $config() instance method; getStaticNodeConfig then synthesizes the corresponding static methods (getType, clone, importJSON, importDOM) at runtime. This PR ports the node classes to $config() and removes the now-redundant boilerplate statics, moving each node's importDOM map into the $config importDOM option while leaving updateFromJSON/exportJSON/createDOM/updateDOM in place. It intentionally does not adopt the separate JSON-schema serialization work - serialization is unchanged.

Ported across lexical (TextNode, TabNode, LineBreakNode, ParagraphNode, RootNode, ArtificialNode), @lexical/rich-text (Heading, Quote), @lexical/link (Link, AutoLink), @lexical/mark, @lexical/hashtag, @lexical/table (Table, Row, Cell), @lexical/code (CodeNode, CodeHighlightNode), @lexical/react and @lexical/extension HorizontalRule, the playground nodes, and the examples. Decorator nodes whose constructors take required arguments keep a minimal clone/importJSON (they must construct with arguments). The base LexicalNode and the fixtures that exist to verify the legacy static-method path are left as-is.

  • Because a $config-based node records its own type via the protocol's accessor, the structurally-identical TabNode stays nominally distinct from TextNode without the statics it previously relied on, so guards like $isTabNode() keep narrowing correctly.
  • Nodes relying on the synthesized clone need a zero-argument constructor, so key-only/leading parameters were given explicit defaults (or redundant pure-delegation constructors were removed) so Node.length === 0.
  • Fixed code that relied on TextNode being incorrectly assignable to TabNode: $getFirstCodeNodeOfLine is now generic over its anchor type.
  • __type is now readonly (it is only ever assigned once, in the constructor); @lexical/yjs's generic property writer casts through a mutable record accordingly (it never actually writes __type, which is intrinsic and filtered out by its equal-value guard).

Tests:

  • Clone tests use the public $cloneWithProperties helper instead of manually pairing clone() with afterCloneFrom().
  • DOM-conversion tests use a new shared $runDOMConversion util that drives the editor's registered conversion cache (the real paste path) instead of a node's static importDOM().

Test plan

New unit tests

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 4, 2026
@vercel

vercel Bot commented Jun 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Jun 8, 2026 6:48pm
lexical-playground Ready Ready Preview, Comment Jun 8, 2026 6:48pm

Request Review

@etrepum etrepum force-pushed the claude/festive-euler-fYz9P branch from 4a9a8e7 to 9841d04 Compare June 4, 2026 22:17
@etrepum etrepum force-pushed the claude/festive-euler-fYz9P branch from 9841d04 to 017f878 Compare June 4, 2026 22:48
@etrepum etrepum force-pushed the claude/festive-euler-fYz9P branch from 017f878 to 7aa5812 Compare June 4, 2026 22:50
@etrepum etrepum force-pushed the claude/festive-euler-fYz9P branch from 7aa5812 to d819825 Compare June 5, 2026 00:26
@etrepum etrepum force-pushed the claude/festive-euler-fYz9P branch from d819825 to fb1daf5 Compare June 5, 2026 01:34
@etrepum etrepum force-pushed the claude/festive-euler-fYz9P branch from fb1daf5 to ad30ae4 Compare June 5, 2026 02:37
@etrepum etrepum force-pushed the claude/festive-euler-fYz9P branch from ad30ae4 to 2a16d96 Compare June 5, 2026 04:17
@etrepum etrepum force-pushed the claude/festive-euler-fYz9P branch from 2a16d96 to e2b9338 Compare June 5, 2026 05:19
@etrepum etrepum force-pushed the claude/festive-euler-fYz9P branch from e2b9338 to 7b9e68a Compare June 5, 2026 17:24
## Description

Building on the additive $config() protocol changes in the previous commit, this
ports the node classes to `$config()` and removes the now-redundant boilerplate
static methods (`getType`, `clone`, `importJSON`, `importDOM`) that
`getStaticNodeConfig` already synthesizes at runtime, moving each node's
`importDOM` map into the `$config` `importDOM` option while leaving
`updateFromJSON`/`exportJSON`/`createDOM`/`updateDOM` in place. (It intentionally
does not adopt the separate JSON-schema serialization work — serialization
methods are unchanged.)

Ported across `lexical` (TextNode, TabNode, LineBreakNode, ParagraphNode,
RootNode, ArtificialNode), `@lexical/rich-text` (Heading, Quote),
`@lexical/link` (Link, AutoLink), `@lexical/mark`, `@lexical/hashtag`,
`@lexical/table` (Table, Row, Cell), `@lexical/code` (CodeNode,
CodeHighlightNode), `@lexical/react` and `@lexical/extension` HorizontalRule,
the playground nodes, and the examples. Decorator nodes whose constructors take
required arguments keep a minimal `clone`/`importJSON` (they must construct with
arguments). The base `LexicalNode` and the test fixtures that exist to verify
the legacy static-method path are left as-is.

- Now that a node records its own `type` via the `$config` accessor introduced
  in the previous commit, the structurally-identical `TabNode` stays nominally
  distinct from `TextNode` without the statics it previously relied on, so
  guards like `$isTabNode()` keep narrowing correctly.
- Nodes relying on the synthesized `clone` need a zero-argument constructor, so
  key-only/leading parameters were given explicit defaults (or redundant
  pure-delegation constructors were removed) so `Node.length === 0`.
- Fixed code that relied on `TextNode` being assignable to `TabNode`:
  `$getFirstCodeNodeOfLine` is now generic over its anchor type.
- `__type` is now `readonly` (it is only ever assigned once, in the
  constructor); `@lexical/yjs`'s generic property writer casts through a mutable
  record accordingly (it never actually writes `__type`, which is intrinsic and
  filtered out by its equal-value guard).

Tests:
- Clone tests use the public `$cloneWithProperties` helper instead of manually
  pairing `clone()` with `afterCloneFrom()`.
- DOM-conversion tests use a new shared `$runDOMConversion` util that drives the
  editor's registered conversion cache (the paste path) rather than calling a
  node's static `importDOM()` directly.

## Test plan

`pnpm run tsc`, `pnpm run flow`, ESLint, and Prettier all pass. The full unit
suite is green: 3728 passed / 1 skipped across `--project unit` and
`--project scripts-unit`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants