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 mutatedNodes to UpdateListener payload #7321

Merged
merged 2 commits into from
Mar 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 63 additions & 20 deletions packages/lexical/src/LexicalEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,14 +231,60 @@ export interface MutationListenerOptions {

const DEFAULT_SKIP_INITIALIZATION = false;

export type UpdateListener = (arg0: {
/**
* The payload passed to an UpdateListener
*/
export interface UpdateListenerPayload {
/**
* A Map of NodeKeys of ElementNodes to a boolean that is true
* if the node was intentionally mutated ('unintentional' mutations
* are triggered when an indirect descendant is marked dirty)
*/
dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
/**
* A Set of NodeKeys of all nodes that were marked dirty that
* do not inherit from ElementNode.
*/
dirtyLeaves: Set<NodeKey>;
/**
* The new EditorState after all updates have been processed,
* equivalent to `editor.getEditorState()`
*/
editorState: EditorState;
/**
* The Map of LexicalNode constructors to a `Map<NodeKey, NodeMutation>`,
* this is useful when you have a mutation listener type use cases that
* should apply to all or most nodes. Will be null if no DOM was mutated,
* such as when only the selection changed.
*
* Added in v0.28.0
*/
mutatedNodes: null | MutatedNodes;
/**
* For advanced use cases only.
*
* Tracks the keys of TextNode descendants that have been merged
* with their siblings by normalization. Note that these keys may
* not exist in either editorState or prevEditorState and generally
* this is only used for conflict resolution edge cases in collab.
*/
normalizedNodes: Set<NodeKey>;
/**
* The previous EditorState that is being discarded
*/
prevEditorState: EditorState;
/**
* The set of tags added with update options or {@link $addUpdateTag},
* node that this includes all tags that were processed in this
* reconciliation which may have been added by separate updates.
*/
tags: Set<string>;
}) => void;
}

/**
* A listener that gets called after the editor is updated
*/
export type UpdateListener = (payload: UpdateListenerPayload) => void;

export type DecoratorListener<T = never> = (
decorator: Record<NodeKey, T>,
Expand Down Expand Up @@ -304,30 +350,27 @@ type Commands = Map<
LexicalCommand<unknown>,
Array<Set<CommandListener<unknown>>>
>;
type Listeners = {
decorator: Set<DecoratorListener>;

export interface Listeners {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
decorator: Set<DecoratorListener<any>>;
mutation: MutationListeners;
editable: Set<EditableListener>;
root: Set<RootListener>;
textcontent: Set<TextContentListener>;
update: Set<UpdateListener>;
};
}

export type Listener =
| DecoratorListener
| EditableListener
| MutationListener
| RootListener
| TextContentListener
| UpdateListener;

export type ListenerType =
| 'update'
| 'root'
| 'decorator'
| 'textcontent'
| 'mutation'
| 'editable';
export type SetListeners = {
[K in keyof Listeners as Listeners[K] extends Set<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(...args: any[]) => void
>
? K
: never]: Listeners[K] extends Set<(...args: infer Args) => void>
? Args
: never;
};

export type TransformerType = 'text' | 'decorator' | 'element' | 'root';

Expand Down
14 changes: 8 additions & 6 deletions packages/lexical/src/LexicalUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ import {
EditorUpdateOptions,
LexicalCommand,
LexicalEditor,
Listener,
MutatedNodes,
RegisteredNodes,
resetEditor,
SetListeners,
Transform,
} from './LexicalEditor';
import {
Expand Down Expand Up @@ -685,6 +685,7 @@ export function $commitPendingUpdates(
dirtyElements,
dirtyLeaves,
editorState: pendingEditorState,
mutatedNodes,
normalizedNodes,
prevEditorState: recoveryEditorState || currentEditorState,
tags,
Expand Down Expand Up @@ -729,19 +730,20 @@ function triggerMutationListeners(
}
}

export function triggerListeners(
type: 'update' | 'root' | 'decorator' | 'textcontent' | 'editable',
export function triggerListeners<T extends keyof SetListeners>(
type: T,
editor: LexicalEditor,
isCurrentlyEnqueuingUpdates: boolean,
...payload: unknown[]
...payload: SetListeners[T]
): void {
const previouslyUpdating = editor._updating;
editor._updating = isCurrentlyEnqueuingUpdates;

try {
const listeners = Array.from<Listener>(editor._listeners[type]);
const listeners = Array.from(
editor._listeners[type] as Set<(...args: SetListeners[T]) => void>,
);
for (let i = 0; i < listeners.length; i++) {
// @ts-ignore
listeners[i].apply(null, payload);
}
} finally {
Expand Down
111 changes: 111 additions & 0 deletions packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ import {
$createParagraphNode,
$createRangeSelection,
$createTextNode,
$extendCaretToRange,
$getChildCaret,
$getEditor,
$getNearestNodeFromDOMNode,
$getNodeByKey,
$getRoot,
$isElementNode,
$isParagraphNode,
$isTextNode,
$parseSerializedNode,
Expand All @@ -49,6 +52,7 @@ import {
ParagraphNode,
RootNode,
TextNode,
UpdateListenerPayload,
} from 'lexical';
import * as React from 'react';
import {
Expand Down Expand Up @@ -76,6 +80,78 @@ import {
TestTextNode,
} from '../utils';

function $getAllNodes(): Set<LexicalNode> {
const root = $getRoot();
const set = new Set<LexicalNode>();
for (const {origin} of $extendCaretToRange($getChildCaret(root, 'next'))) {
set.add(origin);
}
set.add(root);
return set;
}

function computeUpdateListenerPayload(
editor: LexicalEditor,
prevEditorState: EditorState,
hasDOM: boolean,
): UpdateListenerPayload {
return editor.read((): UpdateListenerPayload => {
const dirtyElements: UpdateListenerPayload['dirtyElements'] = new Map();
const dirtyLeaves: UpdateListenerPayload['dirtyLeaves'] = new Set();
const mutatedNodes: UpdateListenerPayload['mutatedNodes'] = new Map();
const tags: UpdateListenerPayload['tags'] = new Set();
const normalizedNodes: UpdateListenerPayload['normalizedNodes'] = new Set();
if (hasDOM) {
for (const node of prevEditorState.read($getAllNodes)) {
const key = node.getKey();
const klass = node.constructor;
const m = mutatedNodes.get(klass) || new Map();
m.set(key, 'destroyed');
mutatedNodes.set(klass, m);
}
}
for (const node of $getAllNodes()) {
const key = node.getKey();
if ($isElementNode(node)) {
dirtyElements.set(key, true);
} else {
dirtyLeaves.add(key);
}
if (hasDOM) {
const klass = node.constructor;
const m = mutatedNodes.get(klass) || new Map();
m.set(
key,
prevEditorState.read(() =>
$getNodeByKey(key) ? 'updated' : 'created',
),
);
mutatedNodes.set(klass, m);
}
}
// This looks like a corner case in element tracking where
// dirtyElements has keys that were destroyed!
for (const [klass, m] of mutatedNodes) {
if ($isElementNode(klass.prototype)) {
for (const [nodeKey, value] of m) {
if (value === 'destroyed') {
dirtyElements.set(nodeKey, true);
}
}
}
}
Comment on lines +134 to +142
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This seems weird, in the case when we do $getRoot().clear() the key of the destroyed ParagraphNode appears in here even though it's not in the editor state. The same behavior doesn't happen for destroyed leaves. IMO we should probably have an efficient way to see which nodes were deleted between prevEditorState and editorState without any DOM dependency (without including ephemeral nodes that were created and then GC'd in the same reconciliation cycle).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It looks like this behavior is intentional-ish but there's no way to know about deleted leaves https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalGC.ts#L103-L107

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

My preference would be to leave this alone for now. I don't really have a use case for dirtyElements or dirtyLeaves and you can't really do anything sound with dirtyElements+dirtyLeaves because they aren't coherent when setEditorState has happened (historic events).

Everything on my plate is better solved by using this new mutatedNodes prop. Just figured I'd point it out while I was in here writing the test coverage.

return {
dirtyElements,
dirtyLeaves,
editorState: editor.getEditorState(),
mutatedNodes,
normalizedNodes,
prevEditorState,
tags,
};
});
}

describe('LexicalEditor tests', () => {
let container: HTMLElement;
let reactRoot: Root;
Expand Down Expand Up @@ -468,6 +544,7 @@ describe('LexicalEditor tests', () => {
init();
const onUpdate = jest.fn();
editor.registerUpdateListener(onUpdate);
const prevEditorState = editor.getEditorState();
editor.update(() => {
$setSelection($createRangeSelection());
editor.update(() => {
Expand All @@ -484,6 +561,10 @@ describe('LexicalEditor tests', () => {
.read(() => $getRoot().getTextContent());
expect(textContent).toBe('Sync update');
expect(onUpdate).toHaveBeenCalledTimes(1);
// Calculate an expected update listener paylaod
expect(onUpdate.mock.calls).toEqual([
[computeUpdateListenerPayload(editor, prevEditorState, false)],
]);
});

it('update does not call onUpdate callback when no dirty nodes', () => {
Expand Down Expand Up @@ -1773,6 +1854,8 @@ describe('LexicalEditor tests', () => {

const paragraphNodeMutations = jest.fn();
const textNodeMutations = jest.fn();
const onUpdate = jest.fn();
editor.registerUpdateListener(onUpdate);
editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, {
skipInitialization: false,
});
Expand All @@ -1781,6 +1864,7 @@ describe('LexicalEditor tests', () => {
});
const paragraphKeys: string[] = [];
const textNodeKeys: string[] = [];
let prevEditorState = editor.getEditorState();

// No await intentional (batch with next)
editor.update(() => {
Expand All @@ -1803,10 +1887,25 @@ describe('LexicalEditor tests', () => {
textNodeKeys.push(textNode3.getKey());
});

expect(onUpdate).toHaveBeenCalledTimes(1);
// Calculate an expected update listener paylaod
expect(onUpdate.mock.lastCall).toEqual([
computeUpdateListenerPayload(editor, prevEditorState, true),
]);

prevEditorState = editor.getEditorState();
await editor.update(() => {
$getRoot().clear();
});

expect(onUpdate).toHaveBeenCalledTimes(2);
// Calculate an expected update listener payload after destroying
// everything
expect(onUpdate.mock.lastCall).toEqual([
computeUpdateListenerPayload(editor, prevEditorState, true),
]);

prevEditorState = editor.getEditorState();
await editor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
Expand All @@ -1818,6 +1917,13 @@ describe('LexicalEditor tests', () => {
root.append(paragraph);
});

expect(onUpdate).toHaveBeenCalledTimes(3);
// Calculate an expected update listener payload after destroying
// everything
expect(onUpdate.mock.lastCall).toEqual([
computeUpdateListenerPayload(editor, prevEditorState, true),
]);

expect(paragraphNodeMutations.mock.calls.length).toBe(3);
expect(textNodeMutations.mock.calls.length).toBe(2);

Expand Down Expand Up @@ -2444,6 +2550,7 @@ describe('LexicalEditor tests', () => {
init();
const onUpdate = jest.fn();
editor.registerUpdateListener(onUpdate);
const prevEditorState = editor.getEditorState();
editor.update(
() => {
$getRoot().append(
Expand All @@ -2460,6 +2567,10 @@ describe('LexicalEditor tests', () => {
.read(() => $getRoot().getTextContent());
expect(textContent).toBe('Sync update');
expect(onUpdate).toHaveBeenCalledTimes(1);
// Calculate an expected update listener paylaod
expect(onUpdate.mock.calls).toEqual([
[computeUpdateListenerPayload(editor, prevEditorState, false)],
]);
});

it('can use discrete after a non-discrete update to flush the entire queue', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/lexical/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export type {
Spread,
Transform,
UpdateListener,
UpdateListenerPayload,
} from './LexicalEditor';
export {
COMMAND_PRIORITY_CRITICAL,
Expand Down