Skip to content

Commit

Permalink
[lexical] Feature: Implement Editor.read and EditorState.read with ed…
Browse files Browse the repository at this point in the history
…itor argument (facebook#6347)
  • Loading branch information
etrepum committed Jul 14, 2024
1 parent 4b720a1 commit 82913db
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 24 deletions.
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 @@ -202,13 +202,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 @@ -328,10 +332,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 @@ -1139,7 +1139,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 @@ -1155,6 +1155,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 {
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,
$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

0 comments on commit 82913db

Please sign in to comment.