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: Implement Editor.read and EditorState.read with editor argument #6347

Merged
merged 10 commits into from
Jul 14, 2024
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,11 @@ Editor States are also fully serializable to JSON and can easily be serialized b
### Reading and Updating Editor State

When you want to read and/or update the Lexical node tree, you must do it via `editor.update(() => {...})`. You may also do
read-only operations with the editor state via `editor.getEditorState().read(() => {...})`. The closure passed to the update or read
call is important, and must be synchronous. It's the only place where you have full "lexical" context of the active editor state,
and providing you with access to the Editor State's node tree. We promote using the convention of using `$` prefixed functions
(such as `$getRoot()`) to convey that these functions must be called in this context. Attempting to use them outside of a read
or update will trigger a runtime error.
read-only operations with the editor state via `editor.read(() => {...})` or `editor.getEditorState().read(() => {...})`.
The closure passed to the update or read call is important, and must be synchronous. It's the only place where you have full
"lexical" context of the active editor state, and providing you with access to the Editor State's node tree. We promote using
the convention of using `$` prefixed functions (such as `$getRoot()`) to convey that these functions must be called in this
context. Attempting to use them outside of a read or update will trigger a runtime error.

For those familiar with React Hooks, you can think of these $functions as having similar functionality:
| *Feature* | React Hooks | Lexical $functions |
Expand All @@ -170,8 +170,10 @@ For those familiar with React Hooks, you can think of these $functions as having

Node Transforms and Command Listeners are called with an implicit `editor.update(() => {...})` context.

It is permitted to do nested updates within reads and updates, but an update may not be nested in a read.
For example, `editor.update(() => editor.update(() => {...}))` is allowed.
It is permitted to do nested updates, or nested reads, but an update should not be nested in a read
or vice versa. For example, `editor.update(() => editor.update(() => {...}))` is allowed. It is permitted
to nest nest an `editor.read` at the end of an `editor.update`, but this will immediately flush the update
and any additional update in that callback will throw an error.

All Lexical Nodes are dependent on the associated Editor State. With few exceptions, you should only call methods
and access properties of a Lexical Node while in a read or update call (just like `$` functions). Methods
Expand All @@ -186,6 +188,16 @@ first call `node.getWritable()`, which will create a writable clone of a frozen
mean that any existing references (such as local variables) would refer to a stale version of the node, but
having Lexical Nodes always refer to the editor state allows for a simpler and less error-prone data model.

:::tip

If you use `editor.read(() => { /* callback */ })` it will first flush any pending updates, so you will
always see a consistent state. When you are in an `editor.update`, you will always be working with the
pending state, where node transforms and DOM reconciliation may not have run yet.
`editor.getEditorState().read()` will use the latest reconciled `EditorState` (after any node transforms,
DOM reconciliation, etc. have already run), any pending `editor.update` mutations will not yet be visible.

:::

### DOM Reconciler

Lexical has its own DOM reconciler that takes a set of Editor States (always the "current" and the "pending") and applies a "diff"
Expand Down
26 changes: 19 additions & 7 deletions packages/lexical-website/docs/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ Editor States are also fully serializable to JSON and can easily be serialized b
### Reading and Updating Editor State

When you want to read and/or update the Lexical node tree, you must do it via `editor.update(() => {...})`. You may also do
read-only operations with the editor state via `editor.getEditorState().read(() => {...})`. The closure passed to the update or read
call is important, and must be synchronous. It's the only place where you have full "lexical" context of the active editor state,
and providing you with access to the Editor State's node tree. We promote using the convention of using `$` prefixed functions
(such as `$getRoot()`) to convey that these functions must be called in this context. Attempting to use them outside of a read
or update will trigger a runtime error.
read-only operations with the editor state via `editor.read(() => {...})` or `editor.getEditorState().read(() => {...})`.
The closure passed to the update or read call is important, and must be synchronous. It's the only place where you have full
"lexical" context of the active editor state, and providing you with access to the Editor State's node tree. We promote using
the convention of using `$` prefixed functions (such as `$getRoot()`) to convey that these functions must be called in this
context. Attempting to use them outside of a read or update will trigger a runtime error.

For those familiar with React Hooks, you can think of these $functions as having similar functionality:
| *Feature* | React Hooks | Lexical $functions |
Expand All @@ -79,8 +79,10 @@ For those familiar with React Hooks, you can think of these $functions as having

Node Transforms and Command Listeners are called with an implicit `editor.update(() => {...})` context.

It is permitted to do nest updates within reads and updates, but an update may not be nested in a read.
For example, `editor.update(() => editor.update(() => {...}))` is allowed.
It is permitted to do nested updates, or nested reads, but an update should not be nested in a read
or vice versa. For example, `editor.update(() => editor.update(() => {...}))` is allowed. It is permitted
to nest nest an `editor.read` at the end of an `editor.update`, but this will immediately flush the update
and any additional update in that callback will throw an error.

All Lexical Nodes are dependent on the associated Editor State. With few exceptions, you should only call methods
and access properties of a Lexical Node while in a read or update call (just like `$` functions). Methods
Expand All @@ -95,6 +97,16 @@ first call `node.getWritable()`, which will create a writable clone of a frozen
mean that any existing references (such as local variables) would refer to a stale version of the node, but
having Lexical Nodes always refer to the editor state allows for a simpler and less error-prone data model.

:::tip

If you use `editor.read(() => { /* callback */ })` it will first flush any pending updates, so you will
always see a consistent state. When you are in an `editor.update`, you will always be working with the
pending state, where node transforms and DOM reconciliation may not have run yet.
`editor.getEditorState().read()` will use the latest reconciled `EditorState` (after any node transforms,
DOM reconciliation, etc. have already run), any pending `editor.update` mutations will not yet be visible.

:::

### DOM Reconciler

Lexical has its own DOM reconciler that takes a set of Editor States (always the "current" and the "pending") and applies a "diff"
Expand Down
9 changes: 8 additions & 1 deletion packages/lexical/flow/Lexical.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,17 @@ declare export class LexicalEditor {
maybeStringifiedEditorState: string | SerializedEditorState,
updateFn?: () => void,
): EditorState;
read<V>(callbackFn: () => V, options?: EditorReadOptions): V;
update(updateFn: () => void, options?: EditorUpdateOptions): boolean;
focus(callbackFn?: () => void, options?: EditorFocusOptions): void;
blur(): void;
isEditable(): boolean;
setEditable(editable: boolean): void;
toJSON(): SerializedEditor;
}
type EditorReadOptions = {
pending?: boolean,
};
type EditorUpdateOptions = {
onUpdate?: () => void,
tag?: string,
Expand Down Expand Up @@ -324,10 +328,13 @@ export interface EditorState {
_readOnly: boolean;
constructor(nodeMap: NodeMap, selection?: BaseSelection | null): void;
isEmpty(): boolean;
read<V>(callbackFn: () => V): V;
read<V>(callbackFn: () => V, options?: EditorStateReadOptions): V;
toJSON(): SerializedEditorState;
clone(selection?: BaseSelection | null): EditorState;
}
type EditorStateReadOptions = {
editor?: LexicalEditor | null;
}

/**
* LexicalNode
Expand Down
15 changes: 14 additions & 1 deletion packages/lexical/src/LexicalEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1096,7 +1096,7 @@ export class LexicalEditor {
/**
* Parses a SerializedEditorState (usually produced by {@link EditorState.toJSON}) and returns
* and EditorState object that can be, for example, passed to {@link LexicalEditor.setEditorState}. Typically,
* deserliazation from JSON stored in a database uses this method.
* deserialization from JSON stored in a database uses this method.
* @param maybeStringifiedEditorState
* @param updateFn
* @returns
Expand All @@ -1112,6 +1112,19 @@ export class LexicalEditor {
return parseEditorState(serializedEditorState, this, updateFn);
}

/**
* Executes a read of the editor's state, with the
* editor context available (useful for exporting and read-only DOM
* operations). Much like update, but prevents any mutation of the
* editor's state. Any pending updates will be flushed immediately before
* the read.
* @param callbackFn - A function that has access to read-only editor state.
*/
read<T>(callbackFn: () => T): T {
$commitPendingUpdates(this);
return this.getEditorState().read(callbackFn, {editor: this});
}

/**
* Executes an update to the editor state. The updateFn callback is the ONLY place
* where Lexical editor state can be safely mutated.
Expand Down
14 changes: 11 additions & 3 deletions packages/lexical/src/LexicalEditorState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ function exportNodeToJSON<SerializedNode extends SerializedLexicalNode>(
return serializedNode;
}

export interface EditorStateReadOptions {
editor?: LexicalEditor | null;
}

export class EditorState {
_nodeMap: NodeMap;
_selection: null | BaseSelection;
Expand All @@ -108,8 +112,12 @@ export class EditorState {
return this._nodeMap.size === 1 && this._selection === null;
}

read<V>(callbackFn: () => V): V {
return readEditorState(this, callbackFn);
read<V>(callbackFn: () => V, options?: EditorStateReadOptions): V {
Copy link
Member

Choose a reason for hiding this comment

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

I'm not a fan of this option like I stated in #6346 (comment)

While the intent is valid, I feel like it can easily lead to pitfalls where the editor is no longer compatible with the EditorState.

For reference, @ivailop7 mentioned a real-use case around DOM (for tables) and this can potentially fail and be hard to debug with this API, whereas editor.read is intuitive and always does what expected.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm totally open to leaving EditorState.read's signature alone and have Editor.read call readEditorState directly. I'll wait until there seems to be some consensus before changing it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I only implemented it this way because it seemed to be the stated preference of @StyleT on the call and @fantactuka in #6346

Copy link
Contributor

Choose a reason for hiding this comment

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

My point was that we shouldn't have 2 ways of reading the state. But we may sugar coat some API. As mentioned in #6346 (comment) the idea is the following:

  1. editor.read calls EditorState.read while passing correct activeEditor as an option
  2. We promote editor.read in the documentation, while keeping EditorState.read for backward compatibility reasons and for more advanced use cases

This allows to:
a. Achieve API consistency
b. Avoid confusing "regular users" with 2 similar APIs
c. Reduce code duplication and establish relation between related APIs "in code"

return readEditorState(
(options && options.editor) || null,
this,
callbackFn,
);
}

clone(selection?: null | BaseSelection): EditorState {
Expand All @@ -122,7 +130,7 @@ export class EditorState {
return editorState;
}
toJSON(): SerializedEditorState {
return readEditorState(this, () => ({
return readEditorState(null, this, () => ({
root: exportNodeToJSON($getRoot()),
}));
}
Expand Down
7 changes: 4 additions & 3 deletions packages/lexical/src/LexicalUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function getActiveEditorState(): EditorState {
'Unable to find an active editor state. ' +
'State helpers or node methods can only be used ' +
'synchronously during the callback of ' +
'editor.update() or editorState.read().',
'editor.update(), editor.read(), or editorState.read().',
);
}

Expand All @@ -110,7 +110,7 @@ export function getActiveEditor(): LexicalEditor {
'Unable to find an active editor. ' +
'This method can only be used ' +
'synchronously during the callback of ' +
'editor.update().',
'editor.update() or editor.read().',
);
}

Expand Down Expand Up @@ -397,6 +397,7 @@ export function parseEditorState(
// function here

export function readEditorState<V>(
editor: LexicalEditor | null,
editorState: EditorState,
callbackFn: () => V,
): V {
Expand All @@ -406,7 +407,7 @@ export function readEditorState<V>(

activeEditorState = editorState;
isReadOnlyMode = true;
activeEditor = null;
activeEditor = editor;

try {
return callbackFn();
Expand Down
129 changes: 128 additions & 1 deletion packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ import {
$createNodeSelection,
$createParagraphNode,
$createTextNode,
$getEditor,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could I add a small request to add a test, that checks if '$getNearestNodeFromDOMNode' can be called within the new "read" method.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sure, it does work, just added tests. Noticed that there's a small inconsistency in that the root node is never set in the _keyToDOMMap. Not sure if is really intentional or not.

It does only run the tests with the latest reconciled state (the current default) because that is really what probably makes the most sense for this use case.

$getNearestNodeFromDOMNode,
$getNodeByKey,
$getRoot,
$isParagraphNode,
$isTextNode,
$parseSerializedNode,
$setCompositionKey,
Expand Down Expand Up @@ -113,7 +116,7 @@ describe('LexicalEditor tests', () => {

let editor: LexicalEditor;

function init(onError?: () => void) {
function init(onError?: (error: Error) => void) {
const ref = createRef<HTMLDivElement>();

function TestBase() {
Expand All @@ -133,6 +136,130 @@ describe('LexicalEditor tests', () => {
return Promise.resolve().then();
}

describe('read()', () => {
it('Can read the editor state', async () => {
init(function onError(err) {
throw err;
});
expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
expect(editor.read(() => $getEditor())).toBe(editor);
const onUpdate = jest.fn();
editor.update(
() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode('This works!');
root.append(paragraph);
paragraph.append(text);
},
{onUpdate},
);
expect(onUpdate).toHaveBeenCalledTimes(0);
// This read will flush pending updates
expect(editor.read(() => $getRoot().getTextContent())).toEqual(
'This works!',
);
expect(onUpdate).toHaveBeenCalledTimes(1);
// Check to make sure there is not an unexpected reconciliation
await Promise.resolve().then();
expect(onUpdate).toHaveBeenCalledTimes(1);
editor.read(() => {
const rootElement = editor.getRootElement();
expect(rootElement).toBeDefined();
// The root never works for this call
expect($getNearestNodeFromDOMNode(rootElement!)).toBe(null);
const paragraphDom = rootElement!.querySelector('p');
expect(paragraphDom).toBeDefined();
expect(
$isParagraphNode($getNearestNodeFromDOMNode(paragraphDom!)),
).toBe(true);
expect(
$getNearestNodeFromDOMNode(paragraphDom!)!.getTextContent(),
).toBe('This works!');
const textDom = paragraphDom!.querySelector('span');
expect(textDom).toBeDefined();
expect($isTextNode($getNearestNodeFromDOMNode(textDom!))).toBe(true);
expect($getNearestNodeFromDOMNode(textDom!)!.getTextContent()).toBe(
'This works!',
);
expect(
$getNearestNodeFromDOMNode(textDom!.firstChild!)!.getTextContent(),
).toBe('This works!');
});
expect(onUpdate).toHaveBeenCalledTimes(1);
});
it('runs transforms the editor state', async () => {
init(function onError(err) {
throw err;
});
expect(editor.read(() => $getRoot().getTextContent())).toEqual('');
expect(editor.read(() => $getEditor())).toBe(editor);
editor.registerNodeTransform(TextNode, (node) => {
if (node.getTextContent() === 'This works!') {
node.replace($createTextNode('Transforms work!'));
}
});
const onUpdate = jest.fn();
editor.update(
() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode('This works!');
root.append(paragraph);
paragraph.append(text);
},
{onUpdate},
);
expect(onUpdate).toHaveBeenCalledTimes(0);
// This read will flush pending updates
expect(editor.read(() => $getRoot().getTextContent())).toEqual(
'Transforms work!',
);
expect(editor.getRootElement()!.textContent).toEqual('Transforms work!');
expect(onUpdate).toHaveBeenCalledTimes(1);
// Check to make sure there is not an unexpected reconciliation
await Promise.resolve().then();
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(editor.read(() => $getRoot().getTextContent())).toEqual(
'Transforms work!',
);
});
it('can be nested in an update or read', async () => {
init(function onError(err) {
throw err;
});
editor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode('This works!');
root.append(paragraph);
paragraph.append(text);
editor.read(() => {
expect($getRoot().getTextContent()).toBe('This works!');
});
editor.read(() => {
// Nesting update in read works, although it is discouraged in the documentation.
editor.update(() => {
expect($getRoot().getTextContent()).toBe('This works!');
});
});
// Updating after a nested read will fail as it has already been committed
expect(() => {
root.append(
$createParagraphNode().append(
$createTextNode('update-read-update'),
),
);
}).toThrow();
});
editor.read(() => {
editor.read(() => {
expect($getRoot().getTextContent()).toBe('This works!');
});
});
});
});

it('Should create an editor with an initial editor state', async () => {
const rootElement = document.createElement('div');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import {
$createParagraphNode,
$createTextNode,
$getEditor,
$getRoot,
ParagraphNode,
TextNode,
Expand Down Expand Up @@ -89,6 +90,12 @@ describe('LexicalEditorState tests', () => {
__text: 'foo',
__type: 'text',
});
expect(() => editor.getEditorState().read(() => $getEditor())).toThrow(
/Unable to find an active editor/,
);
expect(
editor.getEditorState().read(() => $getEditor(), {editor: editor}),
).toBe(editor);
});

test('toJSON()', async () => {
Expand Down
Loading
Loading