-
Notifications
You must be signed in to change notification settings - Fork 1.7k
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-yjs] Bug Fix: clean up dangling text after undo in collaboration #6670
[lexical-yjs] Bug Fix: clean up dangling text after undo in collaboration #6670
Conversation
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
size-limit report 📦
|
@@ -230,4 +230,92 @@ test.describe('Collaboration', () => { | |||
focusPath: [1, 1, 0], | |||
}); | |||
}); | |||
|
|||
test('Undo with two collaborators editing same paragraph', async ({ |
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.
this is failing on the ci :(
the failure is reproducible across different envs so i dont think its a flaky failure
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.
That's the point of this PR, to demonstrate that bug
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.
Yea I created this test in case it helps others with reproducing the bug and confirming a fix, but it's not something that can be merged on its own. Let me know if I should switch this to a draft PR for now, if that's clearer for PR queue management.
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 see, thanks for filing a detailed issue. if its not meant to be merged, converting to draft or adding a [do not merge] to title would work
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.
Title updated 👍
9df14f7
to
2c811ef
Compare
2c811ef
to
4fbe7fe
Compare
@etrepum @ivailop7: @mike-atticus and I spent some more time on this and we think we have a fix... Our understanding of the issue When creating a new paragraph, it starts off with no children. When a user types 'hello', we add two things to the paragraph's The second user then types 'world', which adds only the string 'world' to When the first user then clicks Undo, both the Proposed fix The same PR above added some logic for detecting when a text node was deleted leaving dangling text, and tried to merge said text into the previous text node. However, the checks were too strict: the The first change in this PR is fixing the check to properly handle cases when the
Note: this slightly differs from the test case that @mike-atticus originally submitted. At the time, we didn't really understand how Lexical and YJS synced with each other. Once we found @trueadm's PR, we realised the text node is fully deleted from YJS with the undo operation, so it was basically the same issue. Given this TODO about a "proper" fix, we opted to keep it simple and delete the dangling text. Next steps If Lexical you're are happy with this approach, we'll move the tests over to |
The analysis and proposed fix makes sense to me, although I have never done an audit of the collab code so I could be missing something. I think the important things to consider are:
The fix sounds simple enough that I think it would be backwards compatible and not cause new trouble, but it's hard to see without at least observing a full e2e collab test suite since there really is a lot going on in here. Thank you for investigating further! |
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 the fix largely backwards compatible?
I believe so, yes. We're only changing logic for processing delete
deltas from YJS, which shouldn't impact loading existing documents (pretty sure YJS just gives you insert
events on load). For existing documents that have dangling text at the start of a paragraph, that text will still be deleted from YJS on load by the logic here.
Does it add new edge cases?
Not that we could think of. The change simplifies the overall deletion-handling logic; previously there were two branches that called children.splice(nodeIndex, 1)
, each in very specific circumstances (either delCount === 1
or delCount === nodeSize)
. Now it's "if YMap is deleted, always splice
the CollabTextNode out", regardless of delCount
, with some logic to either move dangling text to another TextNode or clean it up from YJS (delCount
is used to determine what part of the text is left over).
We've pushed the code for the proposed fix as well as the two E2E tests that were failing before the change, covering the two paths for handling of dangling text. Are these what you meant by "full e2e collab test suite"? In case it's helpful for understanding how we got to these tests, I've added some comments inline highlighting where they're failing on main
.
await assertHTML( | ||
page, | ||
html` | ||
<p | ||
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr" | ||
dir="ltr"> | ||
<span data-lexical-text="true">Line 1</span> | ||
</p> | ||
<p class="PlaygroundEditorTheme__paragraph"><br /></p> | ||
`, | ||
); |
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.
Fails on main
with:
expect(received).toEqual(expected) // deep equality
- Expected - 1
+ Received + 5
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Line 1</span>
</p>
- <p class="PlaygroundEditorTheme__paragraph"><br /></p>
+ <p
+ class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
+ dir="ltr">
+ <span data-lexical-text="true">ord</span>
+ </p>
The text is ord
because neither of the two if
checks pass, so Lexical ends up in node._text = spliceString(node._text, offset, delCount, '');
. delCount
is the length of the string from the left user, plus 1 for the deleted YMap
, resulting in the W
also being removed.
await assertHTML( | ||
page, | ||
html` | ||
<p | ||
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr" | ||
dir="ltr"> | ||
<span data-lexical-text="true">normalBOLD</span> | ||
</p> | ||
`, | ||
); |
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.
Fails on main with:
expect(received).toEqual(expected) // deep equality
- Expected - 1
+ Received + 4
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
- <span data-lexical-text="true">normalBOLD</span>
+ <span data-lexical-text="true">normal</span>
+ <strong class="PlaygroundEditorTheme__textBold" data-lexical-text="true">
+ OLD
+ </strong>
The B
character is dropped for the same reason as above.
await assertHTML( | ||
page, | ||
html` | ||
<p | ||
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr" | ||
dir="ltr"> | ||
<span data-lexical-text="true">normalBOLD</span> | ||
</p> | ||
`, | ||
); |
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.
On main, this assert
passes for the left editor (because the refresh got it back in sync) but not the right editor (which will only get back in sync when it refreshes).
await assertHTML( | ||
page, | ||
html` | ||
<p | ||
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr" | ||
dir="ltr"> | ||
<span data-lexical-text="true">Line 1</span> | ||
</p> | ||
<p class="PlaygroundEditorTheme__paragraph"><br /></p> | ||
`, | ||
); |
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.
If you comment out the assert
above, this passes on main
because of this handling. The left editor's change is synced over to the right editor.
children.splice(nodeIndex, 1); | ||
} else if (offset === 0 && delCount === nodeSize) { | ||
// The entire thing needs removing | ||
if (offset === 0 && length === nodeSize) { |
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.
Our observation is that length < nodeSize
when you're just deleting some text from the node, and length === nodeSize
if the YMap
has been deleted. This matches up with the old code's first if
branch (which also had Array.from(node._map.keys()).length === 0
but we couldn't see why that was necessary). It also aligns with the second branch delCount === nodeSize
, where delCount = min(length, deletionSize)
.
// test.skip(!isCollab || IS_MAC); | ||
test.skip(!isCollab); |
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.
Ditto below
// test.skip(!isCollab || IS_MAC); | |
test.skip(!isCollab); | |
test.skip(!isCollab || IS_MAC); |
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 there a reason this should be skipped on Mac? I am not seeing any platform specific code here, I think what this will do is make it harder for contributors to test locally on a Mac because some of the tests just won't run.
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.
No particular reason, just pattern matching existing tests. Works fine on Mac so happy to leave as is.
Pretty confident this is backwards-compatible too. It doesn't modify the stored Ydoc and how to interpret it. I'm happy with the change and tests. |
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.
Given that all the test suites seem to be passing now I don't see any reason not to approve and merge this fix with additional tests. Thank you very much for doing all the hard work on this one!
Description
Adds fix for #6614 where using undo in a collaborative session results in the Lexical editor content getting out of sync with the Yjs doc. The undo operation deletes the
YMap
representing the text node, and the original content string, but it leaves another collaborator's content string dangling at the end. Even though the dangling text is cleaned up on page reload, before that any document edits can cause further document corruption / loss of data.See #6670 (comment) for a more detailed explanation.
Test plan
Before
No tests to detect the collaboration bug that leads to Yjs and Lexical getting out of sync after one user clicks Undo.
After
Playwright tests added which use the side-by-side collaborative playground editors to reproduce the behaviour and confirm that dangling text is cleaned up.