Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 20 commits into
base: main
Choose a base branch
from

Conversation

GermanJablo
Copy link
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 color = createStateKey('color', {
  parse: (value: unknown) => (typeof value === 'string' ? value : undefined),
});

// USAGE
const textNode = new TextNode();
textNode.setState(color, "blue");
const textColor = textNode.getState(color) // "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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Date:   Wed Jan 29 14:41:12 2025 -0800

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

commit 3a140d2
Author: mohammed shaheer kp <[email protected]>
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 <[email protected]>

commit 8e2ede2
Author: Adam Pugh <[email protected]>
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 <[email protected]>
Date:   Mon Jan 27 18:49:34 2025 -0600

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

commit 946a6df
Author: Adam Pugh <[email protected]>
Date:   Mon Jan 27 18:49:29 2025 -0600

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

commit ce93ea6
Author: Adam Pugh <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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ć <[email protected]>
Date:   Fri Jan 24 18:44:05 2025 +0100

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

commit 63958a2
Author: Sherry <[email protected]>
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 <[email protected]>

commit d9f9924
Author: Sherry <[email protected]>
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 <[email protected]>
Date:   Tue Jan 21 06:23:24 2025 +0530

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

commit 2e4a63e
Author: Ivaylo Pavlov <[email protected]>
Date:   Mon Jan 20 02:37:34 2025 +0000

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

commit d319b07
Author: Bob Ippolito <[email protected]>
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 <[email protected]>
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: 涂博闻 <[email protected]>

commit 92a1cd7
Author: Violet Rosenzweig <[email protected]>
Date:   Thu Jan 16 18:44:11 2025 -0500

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

commit f6377a3
Author: Aman Harwara <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>

commit 136a565
Author: Aman Harwara <[email protected]>
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 <[email protected]>
Date:   Wed Jan 15 18:18:03 2025 -0500

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

commit a3ef4f3
Author: Ivaylo Pavlov <[email protected]>
Date:   Wed Jan 15 23:15:39 2025 +0000

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

commit 29d733c
Author: Sherry <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Date:   Sat Jan 11 06:03:30 2025 +0000

    Clean up nested editor update (facebook#7039)

commit bd874a3
Author: Bob Ippolito <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Date:   Wed Jan 8 17:27:15 2025 -0800

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

commit 33e3677
Author: Maksim Horbachevsky <[email protected]>
Date:   Wed Jan 8 14:59:03 2025 -0500

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

commit 7de86e4
Author: James Fitzsimmons <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Date:   Sat Jan 4 01:12:04 2025 +0000

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

commit bd1ef2a
Author: Bob Ippolito <[email protected]>
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 <[email protected]>
Date:   Thu Jan 2 00:20:20 2025 +0100

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

commit 7c21d4f
Author: Bob Ippolito <[email protected]>
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 <[email protected]>
Date:   Wed Jan 1 07:50:39 2025 -0800

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

commit 803391d
Author: Sherry <[email protected]>
Date:   Tue Dec 31 11:26:17 2024 +0800

    [__test__] npm upgrade astro (facebook#7001)

commit 684352b
Author: Christian Grøngaard <[email protected]>
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 <[email protected]>

commit 27b75cc
Author: Sherry <[email protected]>
Date:   Fri Dec 27 11:06:29 2024 +0800

    [__tests__] npm upgrade next (facebook#6996)

commit 05ddbcc
Author: Simon <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Date:   Mon Dec 23 05:19:27 2024 -0300

    Refactor: exportJSON (facebook#6983)

commit e0dafb8
Author: Germán Jabloñski <[email protected]>
Date:   Sat Dec 21 13:59:01 2024 -0300

    feature: expose forEachSelectedTextNode (facebook#6981)

    Co-authored-by: Bob Ippolito <[email protected]>

commit 23715f5
Author: Alex <[email protected]>
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 <[email protected]>
@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
Copy link

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 Jan 31, 2025 6:56pm
lexical-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jan 31, 2025 6:56pm

Comment on lines +91 to +94
'@typescript-eslint/no-unused-vars': [
ERROR,
{args: 'none', argsIgnorePattern: '^_', varsIgnorePattern: '^_'},
],
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I may, I would like to propose this form of opt-out which has legitimate uses. For example, in LexicalNode.test.ts when I define type variables just for the purpose of testing the types.
Also, while developing a WIP it's convenient to be able to mute warnings without having to write comments everywhere.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I prefer this setting also, but maybe there’s some meta style guide reason for not having this? /cc @zurfyx

@@ -134,6 +134,7 @@ describe('table selection', () => {
__parent: null,
__prev: null,
__size: 1,
__state: {},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Perhaps I could have used Object.defineProperty to avoid having to modify the tests, but I preferred to do this because it turned out that there were few tests affected and the changes were trivial.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this will probably be necessary to implement for correct yjs behavior

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I already followed your suggestion to copy the state object in setState, so this suggestion is no longer necessary?
On the other hand, do you know what a unit test for yjs could look like?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I am not familiar with the intricacies of testing lexical-yjs off-hand, I just know its implementation is very problematic because it manipulates node properties directly rather than going through the constructor and well defined serializations. I'd find another node that uses a non-primitive property (something to do with comments or marks probably?) and see if there's any relevant testing. There might not be any good testing for this sort of thing currently.

Comment on lines 24 to 38
Important: we recommend that you use prefixes with low collision probability when defining state keys. For example, if you are making a plugin called `awesome-lexical`, you could do:

```ts
const color = createStateKey('awesome-lexical-color', /** your parse fn */)
const bgColor = createStateKey('awesome-lexical-bg-color', /** your parse fn */)

// Or you can add all your state inside an object:
type AwesomeLexical = {
color?: string;
bgColor?: string;
padding?: number
}
const awesomeLexical = createStateKey('awesome-lexical', /** your parse fn which returns AwesomeLexical type */)

```
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tried to see if collision checking could be done at runtime, but I came to the conclusion that it's not a good idea.

  • Key registration would have to be global
  • It would be difficult to differentiate it from re-executions of the same definition (e.g. useEffect)
  • it might even be better to leave the conflict silent than crash the program. Two plugins using bg-color would probably do the same thing.

In any case, the recommendation here in the documentation would still be valid.

Copy link
Collaborator

Choose a reason for hiding this comment

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

A map+object API would allow this kind of check on first use although to make this efficient you’d use Map<string, StateKey> and have the object be underlying storage (or have a separate object for parsed values)

@@ -1,12 +1,49 @@
# Node Customization
Copy link
Contributor Author

@GermanJablo GermanJablo Jan 31, 2025

Choose a reason for hiding this comment

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

I don't know how you guys handle redirects on the website, but I think it would be nice to rename the url to this title.

Comment on lines +1583 to +1587
// TODO: How could exportJson know which stateKey maps to each key?
// set the default value explicitly
// paragraph.setState(indentKey, 0);
// const json3 = paragraph.exportJSON();
// expect(json3.state).not.toHaveProperty('indent');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As I said about collisions above, I think having a global registry would make things more complex. An alternative I can think of is using setState to remove the default value:

  setState<T extends StateKey>(k: T, v: T['value']) {
    const self = this.getWritable();
+   if (k.parse(undefined) === v) {
+     delete (self.__state as State)[k.key];
+   }
    (self.__state as State)[k.key] = v;
  }

Copy link
Collaborator

Choose a reason for hiding this comment

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

The map+object implementation that I described in the chat would resolve this since the keys of the map are state keys and not strings

Copy link

github-actions bot commented Jan 31, 2025

size-limit report 📦

Path Size
lexical - cjs 29.17 KB (+0.34% 🔺)
lexical - esm 28.98 KB (+0.41% 🔺)
@lexical/rich-text - cjs 38.18 KB (+0.16% 🔺)
@lexical/rich-text - esm 31.02 KB (+0.35% 🔺)
@lexical/plain-text - cjs 36.68 KB (+0.3% 🔺)
@lexical/plain-text - esm 28.29 KB (+0.25% 🔺)
@lexical/react - cjs 40.05 KB (+0.37% 🔺)
@lexical/react - esm 32.46 KB (+0.56% 🔺)

packages/lexical/src/LexicalNode.ts Outdated Show resolved Hide resolved
Comment on lines +1583 to +1587
// TODO: How could exportJson know which stateKey maps to each key?
// set the default value explicitly
// paragraph.setState(indentKey, 0);
// const json3 = paragraph.exportJSON();
// expect(json3.state).not.toHaveProperty('indent');
Copy link
Collaborator

Choose a reason for hiding this comment

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

The map+object implementation that I described in the chat would resolve this since the keys of the map are state keys and not strings

Comment on lines 24 to 38
Important: we recommend that you use prefixes with low collision probability when defining state keys. For example, if you are making a plugin called `awesome-lexical`, you could do:

```ts
const color = createStateKey('awesome-lexical-color', /** your parse fn */)
const bgColor = createStateKey('awesome-lexical-bg-color', /** your parse fn */)

// Or you can add all your state inside an object:
type AwesomeLexical = {
color?: string;
bgColor?: string;
padding?: number
}
const awesomeLexical = createStateKey('awesome-lexical', /** your parse fn which returns AwesomeLexical type */)

```
Copy link
Collaborator

Choose a reason for hiding this comment

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

A map+object API would allow this kind of check on first use although to make this efficient you’d use Map<string, StateKey> and have the object be underlying storage (or have a separate object for parsed values)

@@ -134,6 +134,7 @@ describe('table selection', () => {
__parent: null,
__prev: null,
__size: 1,
__state: {},
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this will probably be necessary to implement for correct yjs behavior

packages/lexical-website/docs/concepts/node-replacement.md Outdated Show resolved Hide resolved
packages/lexical-website/docs/concepts/node-replacement.md Outdated Show resolved Hide resolved
packages/lexical/src/LexicalNode.ts Show resolved Hide resolved
getState<T extends StateKey>(k: T): T['value'] | undefined {
const self = this.getLatest();
// If the state is not set, return the default value
return self.__state[k.key] ?? k.parse(undefined);
Copy link
Collaborator

Choose a reason for hiding this comment

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

With a single object implementation this must always call parse because we don’t know if its directly from JSON or not

packages/lexical/src/LexicalNode.ts Outdated Show resolved Hide resolved
@@ -934,6 +982,7 @@ export class LexicalNode {
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedLexicalNode>,
): this {
this.__state = serializedNode.state || {};
Copy link
Collaborator

Choose a reason for hiding this comment

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

This logic also needs to go into afterCloneFrom, and probably worth adding tests to make sure that this feature doesn’t rewrite history (setting state shouldn’t affect previous reconciled versions of the node)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Any idea how to test it? I tried comparing _editorState with _pendingEditorState without success

Copy link
Collaborator

Choose a reason for hiding this comment

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

let v0: RootNode;
let v1: RootNode;
const vk = createStateKey('vk', { parse: (v) => typeof v === 'number' ? v : null });
editor.update(
  () => {
    v0 = $getRoot();
    v0.setState(vk, 0);
    expect(v0.getState(vk)).toBe(0);
  },
  {discrete: true},
);
const state0 = editor.getEditorState();
editor.update(
  () => {
    v0.setState(vk, 1);
    // normally would use chaining for this but setState doesn't return this
    v1 = v0.getLatest();
    // This is testing getLatest()
    expect(v0.getState(vk)).toBe(1);
    expect(v1.getState(vk)).toBe(1);
    expect(v1.is(v0)).toBe(true);
  },
  {discrete: true},
);
const state1 = editor.getEditorState();
// Test that the correct version is returned and that they are independent
expect(state0.read(() => v0.getState(vk))).toBe(0);
expect(state1.read(() => v1.getState(vk))).toBe(1);
// Test that getLatest is used and not the __state property directly
expect(state0.read(() => v1.getState(vk))).toBe(0);
expect(state1.read(() => v0.getState(vk))).toBe(1);

packages/lexical/src/LexicalNode.ts Outdated Show resolved Hide resolved
packages/lexical/src/LexicalNode.ts Outdated Show resolved Hide resolved
@@ -134,6 +134,7 @@ describe('table selection', () => {
__parent: null,
__prev: null,
__size: 1,
__state: {},
Copy link
Collaborator

Choose a reason for hiding this comment

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

I am not familiar with the intricacies of testing lexical-yjs off-hand, I just know its implementation is very problematic because it manipulates node properties directly rather than going through the constructor and well defined serializations. I'd find another node that uses a non-primitive property (something to do with comments or marks probably?) and see if there's any relevant testing. There might not be any good testing for this sort of thing currently.

Comment on lines +1533 to +1535
const keyForString = createStateKey('keyForString', {
parse: (value) => value as string,
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

This type is a lie because it will return undefined and call it a string. Not sure what the point of an intentionally wrong cast here is?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah, I wanted to test the type (line 1539). What you say I'm doing in 1544 to 1551. Here I could throw an error if value is not typeof string, but for the purposes of this test it doesn't matter.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah but there's no good reason to lie here because we're not testing a runtime invariant. At least (value as string) || '' or something like that would make more sense. Sometimes people look at the tests to see how to use something and this is just lying for no good reason because undefined is always an inhabitant.

Comment on lines +1553 to +1561
// TO-DO:
// It would be a bit nicer if createStateKey also gave a compilation error if parse tried to return null.
// It's not a big deal, because getState does give an error. The problem is that I can't activate it.
// The @expect-error does not work because strict mode is disabled in `tsconfig.test.json`.
const _keyForMaybeNull = createStateKey('keyForMaybeNull', {
parse: (value) => value as string | null,
});
// // @ts-expect-error - null is not a valid value
// const _maybeNullValue = root.getState(keyForMaybeNull);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't really understand your comment here, there's no error because you can explicitly cast unknown to anything that you like without any sort of warning or error in typescript. The only difference from any is that it requires an explicit "trust me bro". It's perfectly valid to have a function with this type signature, it's just the implementation that's wrong.

Suggested change
// TO-DO:
// It would be a bit nicer if createStateKey also gave a compilation error if parse tried to return null.
// It's not a big deal, because getState does give an error. The problem is that I can't activate it.
// The @expect-error does not work because strict mode is disabled in `tsconfig.test.json`.
const _keyForMaybeNull = createStateKey('keyForMaybeNull', {
parse: (value) => value as string | null,
});
// // @ts-expect-error - null is not a valid value
// const _maybeNullValue = root.getState(keyForMaybeNull);
const keyForMaybeNull = createStateKey('keyForMaybeNull', {
parse: (value) => typeof value === 'string' ? value: null,
});
const maybeNullValue = root.getState(keyForMaybeNull);
expect(maybeNullValue).toBe(null);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you look at the State type, I specifically made sure it doesn't allow null. I find it better to incentivize undefined for smaller serialization. If you uncomment these lines, you can see that tsc gives an error in the editor because it uses tsconfig.json, but not in CI because it uses tsconfig.test.json

Copy link
Collaborator

Choose a reason for hiding this comment

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

Your intent to restrict the type doesn't really provide any benefit, given parse: (value) => typeof value === 'string' ? value : null then null will never be serialized if the implementation follows the guidelines that I proposed.

There are use cases where null is an explicit value that's different from the default (which could be undefined, or any other value), and your proposed type makes that use case impossible.

Copy link
Collaborator

Choose a reason for hiding this comment

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

For more clarity here is a tsplayground demonstrating how I think this should all work https://tinyurl.com/rv8jutvt

code below for posterity
import { z } from 'zod';
import { Schema, Option } from 'effect';

export type DeepImmutable<T> = T extends Map<infer K, infer V>
  ? ReadonlyMap<DeepImmutable<K>, DeepImmutable<V>>
  : T extends Set<infer S>
  ? ReadonlySet<DeepImmutable<S>>
  : T extends object
  ? {
      readonly [K in keyof T]: DeepImmutable<T[K]>;
    }
  : T;
export type State = {[Key in string]?: StateValue};
// This could be relaxed in a future version, but for now we only
// support values that are directly JSON serializable
export type StateValue = string | number | boolean | null | undefined | State;
export interface StateKey<
  K extends string = string,
  V extends StateValue = StateValue,
> extends StateKeyConfig<V> {
  readonly key: K;
  // Here we are storing a default for convenience
  readonly value: DeepImmutable<V>;
}
export interface StateKeyConfig<V extends StateValue = StateValue> {
  readonly parse: (value?: unknown) => V;
}
export type StateKeyConfigValue<T extends StateKeyConfig> = ReturnType<T['parse']>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createStateKey<K extends string, T extends StateKeyConfig<any>>(
  key: K,
  config: T,
): StateKey<K, StateKeyConfigValue<T>> {
  return {key, value: config.parse(undefined), parse: config.parse.bind(config)};
}

// It's best for us to demonstrate actual parsers, but we probably
// shouldn't ship a parse library since many users will already have
// something (zod, effect, whatever)
function parseString<Default extends StateValue>(defaultValue: Default): StateKeyConfig<string | Default> {
  return { parse: (v: unknown) => typeof v === 'string' ? v : defaultValue };
}
const skNull = createStateKey("test", parseString(null));
//    ^?
const skUndefined = createStateKey("test", parseString(undefined));
//    ^?
const skString = createStateKey("test", parseString(''));
//    ^?

const zkNull = createStateKey("test", z.string().nullable().catch(null));
//    ^?
const zkUndefined = createStateKey("test", z.string().optional().catch(undefined));
//    ^?
const zkString = createStateKey("test", z.string().catch(''));
//    ^?

function parseSchema<A extends StateValue, I, Default extends StateValue>(schema: Schema.Schema<A, I, never>, defaultValue: Default): StateKeyConfig<A | Default> {
  return { parse: (value) => Option.getOrElse(Schema.decodeUnknownOption(schema)(value), () => defaultValue) };
}

const ekNull = createStateKey("test", parseSchema(Schema.String, null));
//    ^?
const ekUndefined = createStateKey("test", parseSchema(Schema.String, undefined));
//    ^?
const ekString = createStateKey("test", parseSchema(Schema.String, ''));
//    ^?

Comment on lines +188 to +198
type DeepImmutable<T> = T extends Map<infer K, infer V>
? ReadonlyMap<DeepImmutable<K>, DeepImmutable<V>>
: T extends Set<infer S>
? ReadonlySet<DeepImmutable<S>>
: T extends object
? {
readonly [K in keyof T]: DeepImmutable<T[K]>;
}
: T;
type State = {[Key in string]?: string | number | boolean | State};
type StateValue = string | number | boolean | undefined | State;
Copy link
Collaborator

Choose a reason for hiding this comment

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

null should be a valid inhabitant of all of these types

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I find it better to incentivize undefined for smaller serialization.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Not necessary. When null is the default then it won't be serialized. If null is not the default then it's an explicit choice to include null and there's no good reason to prevent that.

packages/lexical/src/LexicalNode.ts Outdated Show resolved Hide resolved
packages/lexical/src/LexicalNode.ts Outdated Show resolved Hide resolved
packages/lexical/src/LexicalNode.ts Show resolved Hide resolved
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.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants