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: registerMutationListener should initialize its existing nodes #6357

Merged
merged 16 commits into from
Jul 14, 2024
Merged
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Change default skipInitialization to be true for transitional backwar…
…ds compatibility
etrepum committed Jul 2, 2024
commit b5d9f54f2c90fc716aa5d0c5a93c917def2dfacc
24 changes: 14 additions & 10 deletions packages/lexical-code/src/CodeHighlighter.ts
Original file line number Diff line number Diff line change
@@ -818,18 +818,22 @@ export function registerCodeHighlighting(
}

return mergeRegister(
editor.registerMutationListener(CodeNode, (mutations) => {
editor.update(() => {
for (const [key, type] of mutations) {
if (type !== 'destroyed') {
const node = $getNodeByKey(key);
if (node !== null) {
updateCodeGutter(node as CodeNode, editor);
editor.registerMutationListener(
CodeNode,
(mutations) => {
editor.update(() => {
for (const [key, type] of mutations) {
if (type !== 'destroyed') {
const node = $getNodeByKey(key);
if (node !== null) {
updateCodeGutter(node as CodeNode, editor);
}
}
}
}
});
}),
});
},
{skipInitialization: false},
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

CodeHighlighter did not previously handle initialization correctly

Copy link
Collaborator

Choose a reason for hiding this comment

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

nice catch

),
editor.registerNodeTransform(CodeNode, (node) =>
codeNodeTransform(node, editor, tokenizer as Tokenizer),
),
Original file line number Diff line number Diff line change
@@ -110,25 +110,29 @@ function CodeActionMenuContainer({
}, [shouldListenMouseMove, debouncedOnMouseMove]);

useEffect(() => {
return editor.registerMutationListener(CodeNode, (mutations) => {
editor.getEditorState().read(() => {
for (const [key, type] of mutations) {
switch (type) {
case 'created':
codeSetRef.current.add(key);
break;

case 'destroyed':
codeSetRef.current.delete(key);
break;

default:
break;
return editor.registerMutationListener(
CodeNode,
(mutations) => {
editor.getEditorState().read(() => {
for (const [key, type] of mutations) {
switch (type) {
case 'created':
codeSetRef.current.add(key);
break;

case 'destroyed':
codeSetRef.current.delete(key);
break;

default:
break;
}
}
}
});
setShouldListenMouseMove(codeSetRef.current.size > 0);
});
});
setShouldListenMouseMove(codeSetRef.current.size > 0);
},
{skipInitialization: false},
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

CodeActionMenuPlugin did not previously handle initialization correctly

);
}, [editor]);

const normalizedLang = normalizeCodeLang(lang);
Original file line number Diff line number Diff line change
@@ -183,17 +183,21 @@ function TableActionMenu({
);

useEffect(() => {
return editor.registerMutationListener(TableCellNode, (nodeMutations) => {
const nodeUpdated =
nodeMutations.get(tableCellNode.getKey()) === 'updated';

if (nodeUpdated) {
editor.getEditorState().read(() => {
updateTableCellNode(tableCellNode.getLatest());
});
setBackgroundColor(currentCellBackgroundColor(editor) || '');
}
});
return editor.registerMutationListener(
TableCellNode,
(nodeMutations) => {
const nodeUpdated =
nodeMutations.get(tableCellNode.getKey()) === 'updated';

if (nodeUpdated) {
editor.getEditorState().read(() => {
updateTableCellNode(tableCellNode.getLatest());
});
setBackgroundColor(currentCellBackgroundColor(editor) || '');
}
},
{skipInitialization: true},
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

TableActionMenuPlugin did handle initialization correctly (it only listens for updates)

);
}, [editor, tableCellNode]);

useEffect(() => {
31 changes: 16 additions & 15 deletions packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx
Original file line number Diff line number Diff line change
@@ -105,24 +105,23 @@ export function LexicalAutoEmbedPlugin<TEmbedConfig extends EmbedConfig>({
}, []);

const checkIfLinkNodeIsEmbeddable = useCallback(
(key: NodeKey) => {
editor.getEditorState().read(async function () {
async (key: NodeKey) => {
const url = editor.getEditorState().read(function () {
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 code was very sketchy previously, and only worked at all because it used linkNode.__url. We should probably have a lint that tells you not to use async for read or update

const linkNode = $getNodeByKey(key);
if ($isLinkNode(linkNode)) {
for (let i = 0; i < embedConfigs.length; i++) {
const embedConfig = embedConfigs[i];

const urlMatch = await Promise.resolve(
embedConfig.parseUrl(linkNode.__url),
);

if (urlMatch != null) {
setActiveEmbedConfig(embedConfig);
setNodeKey(linkNode.getKey());
}
}
return linkNode.getURL();
}
});
if (url === undefined) {
return;
}
for (const embedConfig of embedConfigs) {
const urlMatch = await Promise.resolve(embedConfig.parseUrl(url));
if (urlMatch != null) {
setActiveEmbedConfig(embedConfig);
setNodeKey(key);
}
}
},
[editor, embedConfigs],
);
@@ -146,7 +145,9 @@ export function LexicalAutoEmbedPlugin<TEmbedConfig extends EmbedConfig>({
};
return mergeRegister(
...[LinkNode, AutoLinkNode].map((Klass) =>
editor.registerMutationListener(Klass, (...args) => listener(...args)),
editor.registerMutationListener(Klass, (...args) => listener(...args), {
skipInitialization: true,
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 implementation is not compatible with skipInitialization: false because it resets if it sees key === nodeKey which would fire with 'created', could also be fixed by checking for mutation !== 'created' && key === nodeKey

}),
),
);
}, [checkIfLinkNodeIsEmbeddable, editor, embedConfigs, nodeKey, reset]);
1 change: 1 addition & 0 deletions packages/lexical-react/src/LexicalTablePlugin.ts
Original file line number Diff line number Diff line change
@@ -149,6 +149,7 @@ export function TablePlugin({
}
}
},
{skipInitialization: false},
);

return () => {
8 changes: 5 additions & 3 deletions packages/lexical-website/docs/concepts/listeners.md
Original file line number Diff line number Diff line change
@@ -81,9 +81,10 @@ Get notified when a specific type of Lexical node has been mutated. There are th
Mutation listeners are great for tracking the lifecycle of specific types of node. They can be used to
handle external UI state and UI features relating to specific types of node.

If any existing nodes are in the DOM, the listener will be called immediately with
an updateTag of 'registerMutationListener' where all nodes have the 'created' NodeMutation.
This behavior can be disabled with the skipInitialization option.
If any existing nodes are in the DOM, and skipInitialization is not true, the listener
will be called immediately with an updateTag of 'registerMutationListener' where all
nodes have the 'created' NodeMutation. This can be controlled with the skipInitialization option
(default is currently true for backwards compatibility in 0.16.x but will change to false in 0.17.0).

```js
const removeMutationListener = editor.registerMutationListener(
@@ -94,6 +95,7 @@ const removeMutationListener = editor.registerMutationListener(
console.log(nodeKey, mutation)
}
},
{skipInitialization: false}
);

// Do not forget to unregister the listener when no longer needed!
20 changes: 15 additions & 5 deletions packages/lexical/src/LexicalEditor.ts
Original file line number Diff line number Diff line change
@@ -215,11 +215,15 @@ export type NodeMutation = 'created' | 'updated' | 'destroyed';
export interface MutationListenerOptions {
/**
* Skip the initial call of the listener with pre-existing DOM nodes.
* Default is false.
*
* The default is currently true for backwards compatibility with <= 0.16.1
* but this default is expected to change to false in 0.17.0.
*/
skipInitialization?: boolean;
Copy link
Member

Choose a reason for hiding this comment

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

Is this the best term for users? Initialization describes the work done behind the users, for users it's merely skipping the first one. Do we even need? ⭐️

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We need an option because on the call it was discussed that we need the default behavior to be exactly the current behavior. When the default changes, it should be very rare to have to specify this option, which is why it should have a name that implies that the default is false. I don't have a strong preference as to whether it's called "skipInitialization" or "doNotSendCreateEventForExistingDOM" or whatever.

}

const DEFAULT_SKIP_INITIALIZATION = true;

export type UpdateListener = (arg0: {
dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
dirtyLeaves: Set<NodeKey>;
@@ -834,9 +838,10 @@ export class LexicalEditor {
* One common use case for this is to attach DOM event listeners to the underlying DOM nodes as Lexical nodes are created.
* {@link LexicalEditor.getElementByKey} can be used for this.
*
* If any existing nodes are in the DOM, the listener will be called immediately with
* an updateTag of 'registerMutationListener' where all nodes have the 'created' NodeMutation.
* This behavior can be disabled with the skipInitialization option.
* If any existing nodes are in the DOM, and skipInitialization is not true, the listener
* will be called immediately with an updateTag of 'registerMutationListener' where all
* nodes have the 'created' NodeMutation. This can be controlled with the skipInitialization option
* (default is currently true for backwards compatibility in 0.16.x but will change to false in 0.17.0).
*
* @param klass - The class of the node that you want to listen to mutations on.
* @param listener - The logic you want to run when the node is mutated.
@@ -853,7 +858,12 @@ export class LexicalEditor {
).klass;
const mutations = this._listeners.mutation;
mutations.set(listener, klassToMutate);
if (!(options && options.skipInitialization)) {
const skipInitialization = options && options.skipInitialization;
if (
!(skipInitialization === undefined
? DEFAULT_SKIP_INITIALIZATION
: skipInitialization)
) {
Comment on lines +860 to +865
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Would be const skipInitialization = options?.skipInitialization ?? DEFAULT_SKIP_INITIALIZATION; if we could use null coalescing

this.initializeMutationListener(listener, klassToMutate);
}

Loading