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-yjs] Bug Fix: clean up dangling text after undo in collaboration #6670

Merged
merged 5 commits into from
Nov 5, 2024

Conversation

mike-atticus
Copy link
Contributor

@mike-atticus mike-atticus commented Sep 26, 2024

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.

Copy link

vercel bot commented Sep 26, 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 Nov 5, 2024 7:40pm
lexical-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Nov 5, 2024 7:40pm

@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 Sep 26, 2024
Copy link

github-actions bot commented Sep 26, 2024

size-limit report 📦

Path Size
lexical - cjs 29.92 KB (0%)
lexical - esm 29.78 KB (0%)
@lexical/rich-text - cjs 38.57 KB (0%)
@lexical/rich-text - esm 31.63 KB (0%)
@lexical/plain-text - cjs 37.22 KB (0%)
@lexical/plain-text - esm 28.94 KB (0%)
@lexical/react - cjs 40.33 KB (-0.16% 🔽)
@lexical/react - esm 33.06 KB (0%)

@@ -230,4 +230,92 @@ test.describe('Collaboration', () => {
focusPath: [1, 1, 0],
});
});

test('Undo with two collaborators editing same paragraph', async ({
Copy link
Contributor

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

Copy link
Collaborator

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

Copy link
Contributor Author

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.

Copy link
Contributor

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Title updated 👍

@mike-atticus mike-atticus changed the title [lexical-yjs][lexical-playground] Chore: Add test for collaboration undo bug [do not merge][lexical-yjs][lexical-playground] Chore: Add test for collaboration undo bug Oct 3, 2024
@james-atticus
Copy link
Contributor

james-atticus commented Oct 29, 2024

@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 CollabElementNode _xmlText: a YMap which represents the TextNode and the string 'hello'. These are added to YJS's undo stack together.

The second user then types 'world', which adds only the string 'world' to _xmlText.

When the first user then clicks Undo, both the YMap and 'hello' are deleted, however 'world' remains in _xmlText. @trueadm added handling for this case in here, cleaning up this dangling text when the document is reloaded. However, until that point, Lexical and YJS are out of sync.

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 YMap can be deleted when delCount > 1, as is the case with undo above.

The first change in this PR is fixing the check to properly handle cases when the YMap has been deleted from YJS (if (offset === 0 && length === nodeSize) ...). The other part is updating the dangling text handling:

  • Get the dangling text with spliceString
  • If there's a preceding text node, move the text to that node
  • If there's no preceding text, remove the text from _xmlText; i.e.: the same thing as @trueadm's original fix but immediately

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 Collaboration.test.ts so that we can add checks on the YJS state and speed up the tests (no need for sleep).

@etrepum
Copy link
Collaborator

etrepum commented Oct 30, 2024

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:

  • Is the fix largely backwards compatible (at least such that coherent documents work the same way)? If not, is there another approach that would be more compatible?
  • Does it add new edge cases?

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!

Copy link
Contributor

@james-atticus james-atticus left a 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.

Comment on lines +282 to +292
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>
`,
);
Copy link
Contributor

@james-atticus james-atticus Oct 30, 2024

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.

Comment on lines +362 to +371
await assertHTML(
page,
html`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">normalBOLD</span>
</p>
`,
);
Copy link
Contributor

@james-atticus james-atticus Oct 30, 2024

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.

Comment on lines +381 to +390
await assertHTML(
page,
html`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">normalBOLD</span>
</p>
`,
);
Copy link
Contributor

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).

Comment on lines +302 to +312
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>
`,
);
Copy link
Contributor

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) {
Copy link
Contributor

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).

Comment on lines 240 to 241
// test.skip(!isCollab || IS_MAC);
test.skip(!isCollab);
Copy link
Contributor

Choose a reason for hiding this comment

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

Ditto below

Suggested change
// test.skip(!isCollab || IS_MAC);
test.skip(!isCollab);
test.skip(!isCollab || IS_MAC);

Copy link
Collaborator

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.

Copy link
Contributor

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.

@mike-atticus mike-atticus changed the title [do not merge][lexical-yjs][lexical-playground] Chore: Add test for collaboration undo bug [lexical-yjs] Bug Fix: clean up dangling text after undo in collaboration Oct 30, 2024
@ivailop7
Copy link
Collaborator

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.

Copy link
Collaborator

@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.

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!

@etrepum etrepum added this pull request to the merge queue Nov 5, 2024
Merged via the queue into facebook:main with commit dd65f59 Nov 5, 2024
40 checks passed
@mike-atticus mike-atticus deleted the collaboration-undo-bug-test branch November 5, 2024 23:15
@etrepum etrepum mentioned this pull request Nov 7, 2024
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.

6 participants