Skip to content

[lexical] Feature: add a generic state property to all nodes#7117

Merged
etrepum merged 45 commits intofacebook:mainfrom
GermanJablo:state
Feb 27, 2025
Merged

[lexical] Feature: add a generic state property to all nodes#7117
etrepum merged 45 commits intofacebook:mainfrom
GermanJablo:state

Conversation

@GermanJablo
Copy link
Copy Markdown
Contributor

@GermanJablo GermanJablo commented Jan 31, 2025

This PR enables adding arbitrary state to nodes with automatic JSON serialization.

Motivation:

1. Easier node customization

The following compares adding a 'color' property to a text node using the current node replacement API versus the state API in this PR.

With node replacement API
// IMPLEMENTATION
export type SerializedColoredTextNode = Spread<
{
  color: string;
  type: 'colored';
  version: 1;
},
SerializedTextNode
>;

export class ColoredNode extends TextNode {
__color: string;

constructor(text: string, color?: string, key?: NodeKey): void {
  super(text, key);
  this.__color = color || "defaultColor";
}

static getType(): string {
  return 'colored';
}

setColor(color: string) {
  const self = this.getWritable();
  self.__color = color;
}

getColor(): string {
  const self = this.getLatest();
  return self.__color;
}

static clone(node: ColoredNode): ColoredNode {
  return new ColoredNode(node.__text, node.__color, node.__key);
}

createDOM(config: EditorConfig): HTMLElement {
  const element = super.createDOM(config);
  element.style.color = this.__color || "defaultColor";
  return element;
}

updateDOM(prevNode: ColoredNode, dom: HTMLElement, config: EditorConfig): boolean {
  const isUpdated = super.updateDOM(prevNode, dom, config);
  if (prevNode.__color !== this.__color) {
    dom.style.color = this.__color;
  }
  return isUpdated;
}

static importJSON(serializedNode: SerializedMentionNode): MentionNode {
  const node = new ColoredNode(serializedNode.text, node.serializedNode.color);
  node.setFormat(serializedNode.format);
  node.setDetail(serializedNode.detail);
  node.setMode(serializedNode.mode);
  node.setStyle(serializedNode.style);
  return node;
}

exportJSON(): SerializedHeadingNode {
  return {
    ...super.exportJSON(),
    color: this.getColor()
    tag: this.getTag(),
    type: 'colored',
    version: 1,
  };
}
}

export const nodes = [
  ColoredNode,
  {
    replace: TextNode,
    with: (node: TextNode) => new ColoredNode(node.__text),
    withKlass: ColoredNode,
  },
  // ...
]

// USAGE
const textNode = $createTextNode();
textNode.setColor("blue");
const textColor = textNode.getColor() // "blue"
With state API
// IMPLEMENTATION
const colorState = createState('color', {
  parse: (value: unknown) => (typeof value === 'string' ? value : undefined),
});

// USAGE
const textNode = $createTextNode();
$setState(textNode, colorState, 'blue');
const textColor = $getState(textNode, colorState); // "blue"

2. Composability

If two users replace the same node with the node replacement API, the system fails.
This is particularly relevant when you do not fully control the codebase. For example, when installing a Lexical plugin that requires a custom node.

3. Document metadata

Sometimes it can be useful to store some metadata about the document, such as its ID or creation date.
It can also sometimes be useful to store maps for faster lookups. For example, the comments plugin could have a map of comment IDs to node keys.
This API allows storing that information in RootNode.


Notes:

commit a62a1a6
Author: James Fitzsimmons <119275535+james-atticus@users.noreply.github.com>
Date:   Thu Jan 30 19:13:35 2025 +1100

    [lexical-mark] Feature: include inline decorator nodes in marks (facebook#7086)

commit 881c7fe
Author: Bob Ippolito <bob@redivi.com>
Date:   Thu Jan 30 00:13:00 2025 -0800

    [lexical-utils] Fix: Modify $reverseDfs to be a right-to-left variant of $dfs (facebook#7112)

commit ce2bb45
Author: Nigel Gutzmann <nigelgutzmann@gmail.com>
Date:   Wed Jan 29 14:41:12 2025 -0800

    [lexical-utils] Feature: add reverse dfs iterator (facebook#7107)

commit 3a140d2
Author: mohammed shaheer kp <72137242+mshaheerz@users.noreply.github.com>
Date:   Tue Jan 28 06:19:45 2025 +0530

    [lexical-playground] Bug Fix: Ensure Delete Node handles all node types (facebook#7096)

    Co-authored-by: shaheerkpzaigo <mohammedshaheer@zaigoinfotech.com>

commit 8e2ede2
Author: Adam Pugh <docadam@meta.com>
Date:   Mon Jan 27 18:49:38 2025 -0600

    Listeners Lexical: 3 updates to spelling and grammar - Update listeners.md  (facebook#7100)

commit 9fcc494
Author: Adam Pugh <docadam@meta.com>
Date:   Mon Jan 27 18:49:34 2025 -0600

    Lexical Docs: 2 updates to spelling README.md (facebook#7102)

commit 946a6df
Author: Adam Pugh <docadam@meta.com>
Date:   Mon Jan 27 18:49:29 2025 -0600

    Selection | Lexical: 1 Spelling Update Update selection.md (facebook#7103)

commit ce93ea6
Author: Adam Pugh <docadam@meta.com>
Date:   Mon Jan 27 18:49:25 2025 -0600

    Creating a React Plugin: 1 Grammar Update - Update create_plugin.md (facebook#7104)

commit ed29d89
Author: Adam Pugh <docadam@meta.com>
Date:   Mon Jan 27 18:49:21 2025 -0600

    Working with DOM Events: 2 Spelling and Grammar Updates Update dom-ev… (facebook#7105)

commit 212b70f
Author: James Fitzsimmons <119275535+james-atticus@users.noreply.github.com>
Date:   Mon Jan 27 08:48:09 2025 +1100

    [lexical-yjs] Bug Fix: handle text node being split by Yjs redo (facebook#7098)

commit 6a98a47
Author: Torleif Berger <torleif@outlook.com>
Date:   Fri Jan 24 21:46:45 2025 +0100

    [lexical-react] Bug Fix: Import `JSX` type from React to prevent "Cannot find namespace 'JSX'"-error when type-checking with React 19 (facebook#7080)

commit f8e5968
Author: Tetsuya <62472294+EruditionTu@users.noreply.github.com>
Date:   Sat Jan 25 04:06:57 2025 +0800

    [lexical] Chore: Rename variable and add comments for Safari compositing workaround (facebook#7092)

commit 81c9ab6
Author: Mateo Vuković <195247756+hellovuki@users.noreply.github.com>
Date:   Fri Jan 24 18:44:05 2025 +0100

    Fix: Use already defined RegisteredNodes type (facebook#7085)

commit 63958a2
Author: Sherry <potatowagon@meta.com>
Date:   Tue Jan 21 18:15:21 2025 +0800

    [playground] Bug fix: prevent growing whitespaces in markdown table toggle (facebook#7041)

    Co-authored-by: Bob Ippolito <bob@redivi.com>

commit d9f9924
Author: Sherry <potatowagon@meta.com>
Date:   Tue Jan 21 14:58:08 2025 +0800

    Unrevert [Breaking Change][lexical] Bug Fix: Commit updates on editor.setRootElement(null) facebook#7023 (facebook#7068)

commit 92fa0a3
Author: mohammed shaheer kp <72137242+mshaheerz@users.noreply.github.com>
Date:   Tue Jan 21 06:23:24 2025 +0530

    [lexical-playground] plugins TableOfContent Scroll smooth behaviour A… (facebook#7069)

commit 2e4a63e
Author: Ivaylo Pavlov <ivailo90@gmail.com>
Date:   Mon Jan 20 02:37:34 2025 +0000

    [lexical-playground] Fix Columns Layout Item Overflow (facebook#7066)

commit d319b07
Author: Bob Ippolito <bob@redivi.com>
Date:   Sun Jan 19 14:45:41 2025 -0800

    Change fork modules to use production only when NODE_ENV explicitly set to production (facebook#7065)

commit 46c9c2f
Author: CityHunter <62472294+EruditionTu@users.noreply.github.com>
Date:   Sat Jan 18 13:00:38 2025 +0800

    [lexical] Bug Fix: In the Safari browser, during the compositing event process, the delete key exhibits unexpected behavior. (facebook#7061)

    Co-authored-by: 涂博闻 <tubowen@moonshot.cn>

commit 92a1cd7
Author: Violet Rosenzweig <rosenzweig.violet@gmail.com>
Date:   Thu Jan 16 18:44:11 2025 -0500

    docs: Change "here" link to more descriptive text (facebook#7058)

commit f6377a3
Author: Aman Harwara <amanharwara@protonmail.com>
Date:   Fri Jan 17 02:08:17 2025 +0530

    [lexical-table] Bug Fix: Prevent error if pasted table has empty row (facebook#7057)

commit 0835029
Author: Aman Harwara <amanharwara@protonmail.com>
Date:   Fri Jan 17 00:18:08 2025 +0530

    [lexical-list] Bug Fix: Prevent error when calling formatList when selection is at root (facebook#6994)

commit 940435d
Author: Brayden <1311325+redstar504@users.noreply.github.com>
Date:   Wed Jan 15 16:10:01 2025 -0800

    fix: iOS Autocorrect strips formatting by reporting wrong dataType (facebook#5789)

    Co-authored-by: Bob Ippolito <bob@redivi.com>

commit 136a565
Author: Aman Harwara <amanharwara@protonmail.com>
Date:   Thu Jan 16 04:48:32 2025 +0530

    [lexical-yjs] Feature: Allow passing in custom `syncCursorPositions` function to collab hook (facebook#7053)

commit 415c576
Author: Maksim Horbachevsky <fantactuka@gmail.com>
Date:   Wed Jan 15 18:18:03 2025 -0500

    fix: triple click around inline elements (links) (facebook#7055)

commit a3ef4f3
Author: Ivaylo Pavlov <ivailo90@gmail.com>
Date:   Wed Jan 15 23:15:39 2025 +0000

    [lexical-table] Support table alignment (facebook#7044)

commit 29d733c
Author: Sherry <potatowagon@meta.com>
Date:   Wed Jan 15 21:50:07 2025 +0800

    Revert [Breaking Change][lexical] Bug Fix: Commit updates on editorSetRootElement(null) (facebook#7023) (facebook#7052)

commit 65ce66a
Author: Bob Ippolito <bob@redivi.com>
Date:   Tue Jan 14 14:57:54 2025 -0800

    [lexical] Bug Fix: Normalize selection after applyDOMRange to account for Firefox differences (facebook#7050)

commit bbc07af
Author: Bob Ippolito <bob@redivi.com>
Date:   Tue Jan 14 08:55:46 2025 -0800

    [*] Bug Fix: Use GITHUB_OUTPUT instead of GITHUB_ENV for size-limit action (facebook#7051)

commit c8f27ed
Author: Bob Ippolito <bob@redivi.com>
Date:   Tue Jan 14 06:36:13 2025 -0800

    [Breaking Change][*] Chore: Use terser for optimizing cjs prod build (facebook#7047)

commit 8bd22d5
Author: Bob Ippolito <bob@redivi.com>
Date:   Mon Jan 13 07:09:31 2025 -0800

    [lexical] Bug Fix: Handle MutationObserver/input event re-ordering when using contentEditable inside of an iframe (facebook#7045)

commit 930629c
Author: Ivaylo Pavlov <ivailo90@gmail.com>
Date:   Sat Jan 11 06:03:30 2025 +0000

    Clean up nested editor update (facebook#7039)

commit bd874a3
Author: Bob Ippolito <bob@redivi.com>
Date:   Fri Jan 10 15:23:54 2025 -0800

    [Breaking Change][lexical][lexical-selection][lexical-list] Bug Fix: Fix infinite loop when splitting invalid ListItemNode (facebook#7037)

commit 541fa43
Author: Bob Ippolito <bob@redivi.com>
Date:   Thu Jan 9 12:42:23 2025 -0800

    v0.23.1 (facebook#7035)

    Co-authored-by: Lexical GitHub Actions Bot <>

commit d7abafd
Author: Bob Ippolito <bob@redivi.com>
Date:   Thu Jan 9 08:33:12 2025 -0800

    [Breaking Change][lexical] Bug Fix: Commit updates on editor.setRootElement(null) (facebook#7023)

commit 6add515
Author: Bob Ippolito <bob@redivi.com>
Date:   Wed Jan 8 17:27:15 2025 -0800

    [lexical] Fix TabNode deserialization regression  (facebook#7031)

commit 33e3677
Author: Maksim Horbachevsky <fantactuka@gmail.com>
Date:   Wed Jan 8 14:59:03 2025 -0500

    [lexical-react] Feature: Merge TabIndentionPlugin and ListMaxIndentLevelPlugin plugins (facebook#7018)

commit 7de86e4
Author: James Fitzsimmons <119275535+james-atticus@users.noreply.github.com>
Date:   Wed Jan 8 09:45:25 2025 +1100

    [lexical-mark] Bug Fix: reverse ternary in MarkNode.addID (facebook#7020)

commit 7961130
Author: Bob Ippolito <bob@redivi.com>
Date:   Sun Jan 5 13:55:25 2025 -0800

    v0.23.0 (facebook#7017)

    Co-authored-by: Lexical GitHub Actions Bot <>

commit 2b4252d
Author: Aman Harwara <amanharwara@protonmail.com>
Date:   Sat Jan 4 11:31:19 2025 +0530

    [lexical-yjs] Feature: Expose function to get anchor and focus nodes for given user awareness state (facebook#6942)

commit 8100d6d
Author: Ivaylo Pavlov <ivailo90@gmail.com>
Date:   Sat Jan 4 01:12:04 2025 +0000

    [lexical-playground] Fix table hover actions button position (facebook#7011)

commit bd1ef2a
Author: Bob Ippolito <bob@redivi.com>
Date:   Fri Jan 3 14:25:31 2025 -0800

    [lexical] Bug Fix: Fix registerNodeTransform regression introduced in facebook#6894 (facebook#7016)

commit 85c08b6
Author: Christian Grøngaard <christian@groengaard.dk>
Date:   Thu Jan 2 00:20:20 2025 +0100

    [lexical-playground] Refactor: switch headings test file names (facebook#7008)

commit 7c21d4f
Author: Bob Ippolito <bob@redivi.com>
Date:   Wed Jan 1 12:48:12 2025 -0800

    [Breaking Change][lexical] Feature: Add updateFromJSON and move more textFormat/textStyle to ElementNode (facebook#6970)

commit aaa9009
Author: Bob Ippolito <bob@redivi.com>
Date:   Wed Jan 1 07:50:39 2025 -0800

    [lexical] Bug Fix: Fix getNodes over-selection (facebook#7006)

commit 803391d
Author: Sherry <potatowagon@meta.com>
Date:   Tue Dec 31 11:26:17 2024 +0800

    [__test__] npm upgrade astro (facebook#7001)

commit 684352b
Author: Christian Grøngaard <christian@groengaard.dk>
Date:   Mon Dec 30 05:12:45 2024 +0100

    Documentation: Fix typo "nest nest"->"nest" in README.md (facebook#7000)

    Co-authored-by: Bob Ippolito <bob@redivi.com>

commit 27b75cc
Author: Sherry <potatowagon@meta.com>
Date:   Fri Dec 27 11:06:29 2024 +0800

    [__tests__] npm upgrade next (facebook#6996)

commit 05ddbcc
Author: Simon <bauchet.simon@gmail.com>
Date:   Thu Dec 26 03:37:50 2024 +0100

    [lexical] Bug Fix: Flow is missing some variables and functions (facebook#6977)

commit e79c946
Author: Sherry <potatowagon@meta.com>
Date:   Tue Dec 24 09:54:46 2024 +0800

    v0.22.0 (facebook#6993)

    Co-authored-by: Lexical GitHub Actions Bot <>

commit c415f7a
Author: Sam Zhou <sam@developersam.com>
Date:   Mon Dec 23 10:31:36 2024 -0800

    [lexical-react] Refactor: Replace `React$MixedElement` and `React$Node` with `React.MixedElement` and `React.Node` (facebook#6984)

commit c844a4d
Author: Sherry <potatowagon@meta.com>
Date:   Tue Dec 24 02:30:52 2024 +0800

    [lexical] Fix flow error: change this to any (facebook#6992)

commit 6190033
Author: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
Date:   Mon Dec 23 05:19:27 2024 -0300

    Refactor: exportJSON (facebook#6983)

commit e0dafb8
Author: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
Date:   Sat Dec 21 13:59:01 2024 -0300

    feature: expose forEachSelectedTextNode (facebook#6981)

    Co-authored-by: Bob Ippolito <bob@redivi.com>

commit 23715f5
Author: Alex <UlopLT@gmail.com>
Date:   Fri Dec 20 18:23:27 2024 +0300

    [lexical][lexical-table] Bug fix: TablePlugin:  - check is current selection in target table node (facebook#6979)

    Co-authored-by: alazarev <alazarev@megaputer.ru>
@facebook-github-bot facebook-github-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 Jan 31, 2025
@vercel
Copy link
Copy Markdown

vercel bot commented Jan 31, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
lexical ✅ Ready (Inspect) Visit Preview 💬 Add feedback Feb 23, 2025 9:30pm
lexical-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Feb 23, 2025 9:30pm

@github-actions

This comment was marked as outdated.

Comment on lines +97 to +108
const questionState = makeStateWrapper(
createState('question', {
parse: (v) => (typeof v === 'string' ? v : ''),
}),
);
const optionsState = makeStateWrapper(
createState('options', {
isEqual: (a, b) =>
a.length === b.length && JSON.stringify(a) === JSON.stringify(b),
parse: parseOptions,
}),
);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state wrapper is only used to generate the accessors on the class, mostly to save a bit of code and make it easier to get the types right. I'd be fine removing the state wrapper utility if we want to scope it down, but it's already not in the core.

Comment on lines +126 to +129
getQuestion = questionState.makeGetterMethod<this>();
setQuestion = questionState.makeSetterMethod<this>();
getOptions = optionsState.makeGetterMethod<this>();
setOptions = optionsState.makeSetterMethod<this>();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are just conveniences, could be written out explicitly

).updateFromJSON(serializedNode);
}

constructor(question: string, options: Options, key?: NodeKey) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching to a zero required arg constructor is more compatible with our longer term goals and makes the yjs stuff more sound since it's going to call the constructor with zero args anyway


Originally the only way to customize nodes was using the node replacement API. Recently we have introduced a second way with the `state` property which has some advantages described below.

## Node State (Experimental)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be happy to write more documentation for NodeState and maybe a more complex example as a follow-up, but I didn't want to do too much extra stuff dependent on this feature while waiting for feedback.

[IS_TOKEN]: 'token',
};

export const NODE_STATE_KEY = '$';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This controls the key that goes in the LexicalSerializedNode JSON. Can be whatever we want, I figured something short and unlikely to clash with any existing use made sense. Any string could be used here, even the empty string. In #7189 there is a protocol for nodes to explicitly declare what state they depend on with the option to flatten that state so that it's top-level with other node state rather than cordoned off into this object. The separation of LexicalSerializedNode keys and NodeState keys is very useful when the schema is not known at parse time and it allows any extra metadata to pass through (e.g. when using multiple editor configs for the same data, or separate versions of the same editor).

this.__next = null;
Object.defineProperty(this, '__state', {
configurable: true,
enumerable: false,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to effectively hide __state from code like yjs that does weird stuff with Object.entries. Not strictly required, we could use a symbol instead, or leave it exposed, since yjs has been updated to be compatible with NodeState.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also so as not to break a handful of tests

/** @internal */
__next: null | NodeKey;
/** @internal */
__state?: NodeState<this>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NodeState object is optional so it's practically zero cost unless you're using it.

* */
exportJSON(): SerializedLexicalNode {
// eslint-disable-next-line dot-notation
const state = this.__state ? this.__state.toJSON() : undefined;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NodeState returns the JSON to inject directly into SerializedLexicalNode to support flattening, as implemented in #7189

* will trigger a copy of the prevNode's NodeState with the node property
* updated.
*/
readonly node: LexicalNode;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be a WeakRef (someday…) or a WeakSet but the cost of that extra object is probably higher than the cost of referencing at most one old version of the node.

Copy link
Copy Markdown
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to put this in an approved state, but since I worked on this PR and it's a significant new API I will wait for feedback before merging.

Copy link
Copy Markdown
Member

@zurfyx zurfyx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the most useless comment in the PR, but I just want to publicly acknowledge this is awesome foundational work, thank you @GermanJablo for the multiple iterations on this idea and @etrepum for the close collaboration on the subject. This does solve a major pain point in Lexical where previously product teams and third-party extensions overused inheritance or Node replacement.

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.

4 participants