-
Notifications
You must be signed in to change notification settings - Fork 1.8k
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
Conversation
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
size-limit report 📦
|
2a1d574
to
a560f2f
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks Bob! This is intentional:
- Conceptually Nodes aren't "created" when you register the function.
- The listeners weren't meant to be called immediately upon registration (the Root listener was meant to be a one-off).
Note that this is also a dangerous breaking change, the API is backward compatible but the behavior is not. Internally, we commonly pair this function with a Map to extend the behavior of the Node independently and these now repeated events can cause trouble with the initialization.
#4099 is a potential option to mitigate the issue. I think it is reasonable to have this build-in via a property (don't have strong opinions on 2.) but at the same time I'd advocate for a more flat API instead of yet another configuration object.
Would like to hear what other think about this as well.
|
Other than the number of calls, the breaking API change is unlikely to cause problems because it will just emit an initial created event where previously you had to do a separate read to discover them and this was only implemented correctly one time in the monorepo (it was implemented wrong in two places, and not implemented at all in several other places). Implementing Alternatively the new behavior could be added with an option, or a separate function/method, but I would prefer the breaking change because it makes it much easier to write correct code and will probably even fix a few latent bugs. |
It depends on how editor's state is initialized. If
+1, I did check registerMutationListener + $nodesOfType (commonly used to handle initial nodes) internally and it relies on nodeID which should avoid double initialization, but there could be usages that just go and mutate some DOM for 'created' mutation without tracking nodeID at all |
Re: |
An opt-in migration path sounds fine to me, we can use the existing option argument for that and explicitly use |
This is not intentional, this function should look into replacements like we do elsewhere.
@fantactuka Can you expand on what a simpler API means for you? In either case, it would be a one-liner. The concern I raised about the current implementation is that previously created nodes are also reported as |
The export function registerNodesOfTypeListener(
editor: LexicalEditor,
klass: Klass<LexicalNode>,
listener: (nodes: Set<NodeKey>) => void,
fireImmediately: void | boolean = false,
): () => void {
let nodes = new Set<NodeKey>();
return editor.registerMutationListener(klass, (mutations, {updateTags}) => {
let newNodes: Set<NodeKey> | null = null;
for (const [key, mutation] of mutations) {
const created = mutation === 'created';
const destroyed = mutation === 'destroyed';
if (created || destroyed) {
if (newNodes === null) {
newNodes = new Set(nodes);
}
if (created) {
newNodes.add(key);
}
if (destroyed) {
newNodes.delete(key);
}
}
}
if (newNodes !== null) {
nodes = newNodes;
if (fireImmediately || !updateTags.has('registerMutationListener')) {
listener(nodes);
}
}
}, {skipInitialization: false});
} Other than a potential complexity/performance trade-off of tracking previously created nodes separately (which is opt-in here), what are the use cases for having a mutation listener that ignores initial creates? I think in most cases the existing code is just wrong (doesn't support lazy loading at all or doesn't correctly track existing nodes), and if it is right then it is probably already has "defenses" to work around the fact that it is not seeing a complete picture of the DOM nodes (e.g. tracking nodes in a separate map so that an update can be considered to be a create) so the worst case is usually just doing extra work (which would've happened in the TOC plug-in if I didn't opt-in to |
@ivailop7 good catch, the new |
Thanks! |
Leaving to @zurfyx to merge |
for (const k of nodeMap.keys()) { | ||
nodeMutationMap.set(k, 'created'); | ||
} | ||
if (nodeMutationMap.size > 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the sake of predictability I wonder if we should always call it regardless. This is sort of inline with my previous comment but less radical, I understand that libraries like Rxjs follow similar patterns.
⭐️ This predicatibility also gives users more flexibility and the option flag becomes more of a convenience rather than a must-have.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have a strong preference one way or the other, correct code won't notice a difference. The current zero check is "more" backwards compatible in that the behavior only changes if the mutation is registered after the initial reconciliation, so I think more tests will end up having to change a bit (the ones based on call count rather than meaningful substance). Since we ended up with an option and a deprecation phase I think either is fine. Anyone else have an opinion about this?
* 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; |
There was a problem hiding this comment.
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? ⭐️
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Description
When
registerMutationListener
is called, if there are any existing nodes with that type, then the listener should immediately be called once with any existing nodes in the DOM from the previously reconciled state.This makes it easier to implement mutation listeners correctly, especially for plug-ins that may be loaded lazily, and mirrors the behavior for
registerRootListener
andregisterNodeTransform
where it is expected that the code will be run for existing content (though there are other types listeners that do not have this behavior, I think it really makes the most sense for mutation listeners to behave this way). Some existing mutation listeners either did not support lazy loading, or did this incorrectly (e.g. by checking for an exact node type match instead of considering node replacement as in #6349).This new "synthetic" mutation can be detected from the listener by checking for the
registerMutationListener
update tag, and it can be skipped entirely with the new{skipInitialization: true}
option argument (mutation listeners registered in this way will have the same semantics as before this PR).Closes #6356
Closes #6349
Test plan
Unit tests are included. Only one existing unit test needed to be changed to use the new
{skipInitialization: true}
option.I did have to fix an egregious react rules violation in CodeActionMenuPlugin that caused an infinite rendering loop with this change, the registration needed to be wrapped in
useEffect
. The previous behavior was basically a memory & performance leak, fortunately only in playground code.