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

[Breaking Change][lexical] Feature: New update tag: skip-dom-selection, $onUpdate now always called #6894

Merged
merged 16 commits into from
Dec 13, 2024

Conversation

etrepum
Copy link
Collaborator

@etrepum etrepum commented Dec 2, 2024

Description

  • Adds documentation about focus to the selection documentation.
  • Adds a 'skip-dom-selection' update tag. Both of these are relevant to a very common FAQ which is about how to avoid the editor taking over the selection with a programmatic update.
  • Changes the type of updateDOM to be more correct, the prevNode argument should always be this (the same class, but a different instance). This fixes some subclassing issues.
  • Changes the behavior of onUpdate and $onUpdate to always be called. Previously they would be skipped if the update containing them did not make any nodes dirty. It's often useful to use $onUpdate to attach some logic in a dispatchCommand that does not need to update nodes or the selection but needs to do something with the reconciled DOM or editor afterwards.
  • registerNodeTransform now only marks relevant nodes as dirty, instead of all nodes
  • Some of the implementation details of Autocomplete nodes were changed to be more compatible with collab, though issues still remain (Bug: Autocomplete on Collab - suggestion appears in a different session while composing on another. #6844)

Closes #6514
Clsoes #4474

Upgrade Notes

If you have code that expects onUpdate or $onUpdate to not be called if the update did not make any nodes or the selection dirty, then you will have to change your code accordingly.

Now when registerNodeTransform is called, only relevant nodes (by type, including support for withKlass) are marked dirty. Previously, all nodes in the editor were marked as dirty when any transform was registered (except RootNode, which was a special case).

Test plan

Unit tests were added for the new behavior of onUpdate / $onUpdate and the 'skip-dom-selection' tag.

Copy link

vercel bot commented Dec 2, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
lexical ✅ Ready (Inspect) Visit Preview 💬 Add feedback Dec 13, 2024 7:25am
lexical-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Dec 13, 2024 7:25am

@facebook-github-bot facebook-github-bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Dec 2, 2024
Copy link

github-actions bot commented Dec 2, 2024

size-limit report 📦

Path Size
lexical - cjs 31.09 KB (0%)
lexical - esm 30.96 KB (0%)
@lexical/rich-text - cjs 40.13 KB (0%)
@lexical/rich-text - esm 32.8 KB (0%)
@lexical/plain-text - cjs 38.71 KB (0%)
@lexical/plain-text - esm 30.12 KB (0%)
@lexical/react - cjs 42.05 KB (0%)
@lexical/react - esm 34.21 KB (0%)

@etrepum etrepum changed the title [lexical] Feature: New update tag: skip-dom-selection [Breaking Change][lexical] Feature: New update tag: skip-dom-selection Dec 6, 2024
currentAnchorNodeDOM,
editor._config,
)));
($isTextNode(currentAnchorNode) &&
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

These changes are just to allow TypeScript to infer that the type of currentAnchorNode and previousAnchorNode are both TextNode which is required to satisfy updateDOM. Strictly speaking it could still violate constraints if they have different TextNode-compatible constructors, e.g. !(previousAnchorNode instanceof currentAnchorNode.constructor) but this is not something that TypeScript can detect and we already have a runtime invariant to ensure the constructor never changes for a node with a given key.

packages/lexical-website/docs/concepts/selection.md Outdated Show resolved Hide resolved
ivailop7
ivailop7 previously approved these changes Dec 11, 2024
@etrepum
Copy link
Collaborator Author

etrepum commented Dec 12, 2024

It looks like there's one failing flaky collab+firefox e2e test that now reliably fails in this PR. I'll investigate further before merge.

1 failed
[1]     [firefox] › packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs:76:3 › Autocomplete › Can autocomplete in the same format as the original text 

zurfyx
zurfyx previously approved these changes Dec 12, 2024
@etrepum etrepum changed the title [Breaking Change][lexical] Feature: New update tag: skip-dom-selection, $onUpdate now always called [NOMERGE][Breaking Change][lexical] Feature: New update tag: skip-dom-selection, $onUpdate now always called Dec 12, 2024
@etrepum
Copy link
Collaborator Author

etrepum commented Dec 13, 2024

So what's currently happening here is:

  • Left editor changes the font size
  • Left editor types a character or two
  • Right editor sees the new text node with font size change, considers it a conflict for whatever reason, and creates a new paragraph
  • Right editor's new paragraph ends up moving the browser's focus to the right editor, so the rest of the characters end up being typed into the Right editor's new paragraph

@etrepum
Copy link
Collaborator Author

etrepum commented Dec 13, 2024

Autocomplete is a necessary condition for this, so it seems that interaction between autocomplete and collab is the real problem here

@etrepum etrepum dismissed stale reviews from zurfyx and ivailop7 via cd6cce1 December 13, 2024 07:19
@etrepum etrepum requested a review from zurfyx December 13, 2024 16:57
@etrepum etrepum changed the title [NOMERGE][Breaking Change][lexical] Feature: New update tag: skip-dom-selection, $onUpdate now always called [Breaking Change][lexical] Feature: New update tag: skip-dom-selection, $onUpdate now always called Dec 13, 2024
Copy link
Collaborator Author

@etrepum etrepum left a comment

Choose a reason for hiding this comment

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

This is passing all tests now, just needs a re-approval. Added some commentary on the new changes.

@@ -52,7 +52,12 @@ test.describe('Autocomplete', () => {
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Sort by alpha</span>
<span data-lexical-text="true"></span>
<span
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 changed the autocomplete node to render more than an empty span in collab, but set its display to none. This makes it easier to see what's going on in the DOM. I think ultimately there should be some other solution for autocomplete in collab that doesn't get synchronized.

@@ -960,7 +970,10 @@ export class LexicalEditor {
registeredNodes.push(registeredReplaceWithNode);
}

markAllNodesAsDirty(this, klass.getType());
markNodesWithTypesAsDirty(
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 renamed this internal function because now it actually does what we need it to do

@@ -607,7 +607,8 @@ export function $commitPendingUpdates(
editor._editable &&
// domSelection will be null in headless
domSelection !== null &&
(needsUpdate || pendingSelection === null || pendingSelection.dirty)
(needsUpdate || pendingSelection === null || pendingSelection.dirty) &&
!tags.has('skip-dom-selection')
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 is the logic that skips DOM selection with that tag

@@ -1005,6 +1006,7 @@ function $beginUpdate(

const shouldUpdate =
editor._dirtyType !== NO_DIRTY_NODES ||
editor._deferred.length > 0 ||
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 is where we defer the update whenever there is an onUpdate, so you can attach the callback, do some mutations in another update issued that tick, and still get a callback

@@ -72,7 +72,6 @@ import {
internalGetActiveEditorState,
isCurrentlyReadOnlyMode,
triggerCommandListeners,
updateEditor,
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 didn't see a reason to use this internal function here

// Mark all existing text nodes as dirty
updateEditor(
editor,
export function markNodesWithTypesAsDirty(
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Previously whenever a transform was registered we marked all nodes in the editor as dirty, which was surprisingly the biggest factor in the collab+autocomplete+selection issue. Now it only marks relevant nodes as dirty, which in this case is none. We can use the same internal facility developed for registerMutationListener initialization to find the matching nodes by type in the reconciled editor state to determine which ones should be marked as dirty.

node.markDirty();
for (const nodeMap of dirtyNodeMaps) {
for (const node of nodeMap.values()) {
node.markDirty();
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 don't need to check if the nodes are attached, worst case they just end up being re-inserted in the nodeMap and get GC'd on reconciliation.

@@ -1825,17 +1833,26 @@ export function getCachedTypeToNodeMap(
);
let typeToNodeMap = cachedNodeMaps.get(editorState);
if (!typeToNodeMap) {
typeToNodeMap = new Map();
typeToNodeMap = computeTypeToNodeMap(editorState);
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 is just extracting some logic to a function, nothing was changed here, I thought I might need to do it with a live EditorState but then realized that the frozen one will work even better since all nodes in the live editorstate are either in the frozen one or are already dirty.

@@ -29,6 +29,7 @@ export type {
SerializedEditor,
Spread,
Transform,
UpdateListener,
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 thought I was going to need to use tags in the updateListener in the Autocomplete plugin and noticed this type was not exported

@etrepum etrepum added this pull request to the merge queue Dec 13, 2024
Merged via the queue into facebook:main with commit 66f805c Dec 13, 2024
43 checks passed
fkling added a commit to sourcegraph/cody that referenced this pull request Dec 17, 2024
…ts context

Changing the focus behavior was more difficult than expected. Apparently
when Lexical has a Selection then it will 'steal' the focus whenever
changes to its content is made.

There have been made changes to Lexical just recently that would allow
us to avoid this (facebook/lexical#6894) but we
haven't updated to this version yet and updating this closely before the
EAP seems risky.

I found a workaround in one of the discussions that suggests to set the
selection to `null` when making a change.

In order to do this I refactored some of the function to make them more
composable. Instead of calling `editor.update` directly these functions
are now supposed to be called from `editor.update`.

Since I've added a helper for creating a promise-version of
`editor.update` anyway, I also addressed one of the issues I've
encountered in the last PR, namely that `onUpdate` is not called when
the editor state doesn't change.
This will btw also be fixed on the Lexical side by the above mentioned PR.
vovakulikov pushed a commit to sourcegraph/cody that referenced this pull request Dec 18, 2024
…ts context (#6385)

Closes srch-1483

Changing the focus behavior was more difficult than expected. Apparently
when Lexical has a Selection then it will 'steal' the focus whenever
changes to its content is made.

There have been made changes to Lexical just recently that would allow
us to avoid this (facebook/lexical#6894) but we
haven't updated to this version yet and updating this closely before the
EAP seems risky.

I found a workaround in one of the discussions that suggests to set the
selection to `null` when making a change.

In order to do this I refactored some of the function to make them more
composable. Instead of calling `editor.update` directly these functions
are now supposed to be called from `editor.update`.

Since I've added a helper for promisifying `editor.update` anyway, I
also addressed one of the issues I've encountered in the last PR, namely
that `onUpdate` is not called when the editor state doesn't change. To
prevent accidentally calling it in a way that would not resolve the
promise, the callback has to return a boolean.
This will btw also be fixed on the Lexical side by the above mentioned
PR.


## Test plan

Manual testing 


https://github.com/user-attachments/assets/296cca9e-b8e3-433e-a39a-befbb8fe2017
vovakulikov pushed a commit to sourcegraph/cody that referenced this pull request Dec 18, 2024
…ts context (#6385)

Closes srch-1483

Changing the focus behavior was more difficult than expected. Apparently
when Lexical has a Selection then it will 'steal' the focus whenever
changes to its content is made.

There have been made changes to Lexical just recently that would allow
us to avoid this (facebook/lexical#6894) but we
haven't updated to this version yet and updating this closely before the
EAP seems risky.

I found a workaround in one of the discussions that suggests to set the
selection to `null` when making a change.

In order to do this I refactored some of the function to make them more
composable. Instead of calling `editor.update` directly these functions
are now supposed to be called from `editor.update`.

Since I've added a helper for promisifying `editor.update` anyway, I
also addressed one of the issues I've encountered in the last PR, namely
that `onUpdate` is not called when the editor state doesn't change. To
prevent accidentally calling it in a way that would not resolve the
promise, the callback has to return a boolean.
This will btw also be fixed on the Lexical side by the above mentioned
PR.


## Test plan

Manual testing 


https://github.com/user-attachments/assets/296cca9e-b8e3-433e-a39a-befbb8fe2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Docs: Selection reconciliation & focus FAQs
4 participants