Skip to content

[lexical][lexical-yjs] Bug Fix: avoid empty-paragraph echo and splice crash on collab undo (#6614) and don't preserve tags on non-dirty update#8651

Merged
etrepum merged 4 commits into
facebook:mainfrom
ivanscm:fix/yjs-collab-undo-empty-paragraph
Jun 9, 2026
Merged

[lexical][lexical-yjs] Bug Fix: avoid empty-paragraph echo and splice crash on collab undo (#6614) and don't preserve tags on non-dirty update#8651
etrepum merged 4 commits into
facebook:mainfrom
ivanscm:fix/yjs-collab-undo-empty-paragraph

Conversation

@ivanscm

@ivanscm ivanscm commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Description

Follow-up to #6614 (and the already-merged #6670). Two remaining ways an undo in a
collaborative session corrupts the shared document:

  1. Empty-paragraph echo. $ensureEditorNotEmpty() runs in the sync onUpdate
    inside its own untagged editor.update(). During an undo the Lexical root can be
    transiently empty while the Yjs doc still has content; the untagged insert is then
    treated as a local edit and echoed back to Yjs, duplicating the paragraph for other
    clients. Fix: only re-add the recovery paragraph when the shared (Yjs) root is
    genuinely empty — binding.root.isEmpty() (V1) / binding.root.length === 0 (V2).

  2. Splice crash. Reconciling an undo against a concurrent remote edit can reach
    CollabElementNode.splice with no node at index and nothing to insert, which
    throws invariant (Error #94: splice: could not find collab element node) and
    corrupts the session. Fix: treat that state as a no-op recovery instead of crashing.

Seeded, delta-minimized fuzzing of the binding surfaced two further corruptions, also
fixed here:

  1. Concurrent-reconcile crashes. Applying an event that references a node
    concurrently removed in Lexical (getNode() is null) or a shared type concurrently
    deleted (no __type) threw binding invariants (could not find element node,
    Expected shared type to include type attribute, …) and desynced the session. Fix:
    skip those as no-op recoveries in Collab{Element,Text,Decorator}Node.

  2. Dropped local edit → live/persisted desync (lexical core). $commitPendingUpdates
    reset editor._updateTags only on dirty commits, but tags come from the tag option
    regardless and the update listener fires on every commit. A remote sync that
    reconciled to nothing (e.g. a concurrently-removed target) thus leaked its
    COLLABORATION tag into the peer's next local edit, which syncLexicalUpdateToYjs
    skips — so that edit never reached Yjs and the peer's live editor diverged from its own
    persisted document. Fix: always reset _updateTags once listeners have observed them.
    This also exposed a latent setEditorState bug (it cached _updateTags across an
    internal force-commit and tagged the stale Set, breaking history's undo classification),
    now reading _updateTags fresh after the commit.

Test plan

  • prettier, eslint, tsc pass locally.
  • Existing collaboration e2e (Collaboration.spec.mjs, incl. the [lexical-yjs] Bug Fix: clean up dangling text after undo in collaboration #6670 undo/refresh
    tests) continues to pass.
  • Manual two-window repro from Bug: undo causes unexpected paragraph deletion in collaborative editor #6614 (real WebSocket transport): after undo + refresh
    the collaborator's text is preserved, no duplicate paragraphs, no Error #94.
  • New deterministic regression tests using only exported @lexical/yjs APIs over a
    mock async transport — concurrent-reconcile crash repros and the dropped-local-edit
    Yjs-desync repro fail without these changes — plus core unit tests for the
    _updateTags no-op leak and setEditorState tag preservation.
  • Full unit suite and chromium + firefox e2e (rich-text, plain-text, collab, collab-v2)
    green; the remaining macOS/webkit collab job is unaffected by this JS-only change.

The empty-paragraph echo itself stays a timing-dependent, mid-reconcile cross-client
transient that the synchronous e2e harness collapses, so it has no standalone e2e test.
Driving the exported sync functions over a mock async transport preserves that transient,
which is what makes the crash and desync defects above reproducible deterministically at
the unit level.

@meta-cla

meta-cla Bot commented Jun 8, 2026

Copy link
Copy Markdown

Hi @ivanscm!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@vercel

vercel Bot commented Jun 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Jun 9, 2026 2:36pm
lexical-playground Ready Ready Preview, Comment Jun 9, 2026 2:36pm

Request Review

@meta-cla meta-cla 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 Jun 8, 2026
@meta-cla

meta-cla Bot commented Jun 8, 2026

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It would be good to see unit tests for this, I understand that the iframe e2e tests are non-deterministic but I don't see why we couldn't put together unit tests for them?

@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Jun 8, 2026
etrepum pushed a commit to etrepum/lexical that referenced this pull request Jun 8, 2026
…raph echo (facebook#8651)

Adds a regression test that reproduces the empty-paragraph echo on collaborative
undo without the synchronous test harness (which serializes away the
mid-reconcile transient). Two peers are wired with the exported @lexical/yjs
binding infrastructure over a mock async transport; a fuzzer-found, minimized
sequence of ordinary edits + undos drives the binding into the buggy state.

Without the fix the undo recovery echoes a spurious empty paragraph into the
shared document (the test sees 2 paragraphs); with the fix it sees 1.

https://claude.ai/code/session_017fxm6RcQegBrGL2kXDJBo8
etrepum pushed a commit to etrepum/lexical that referenced this pull request Jun 9, 2026
…st removed nodes

Fuzzing concurrent collaborative editing (2-3 peers, async delivery, edits + undo)
surfaced reconciliation invariants that crash and corrupt the session when an
event arrives for a node that was concurrently removed, or a delta references a
shared type that was concurrently deleted:

  - syncChildrenFromYjs / syncPropertiesFromYjs (element & decorator) /
    syncPropertiesAndTextFromYjs: getNode() is null
  - applyChildrenYjsDelta: an insert references a deleted shared type with no
    __type ("Expected shared type to include type attribute")

These are now skipped gracefully (the collab tree still updates and self-heals
when the parent reconciles), so the peers converge instead of throwing. A fuzz
sweep of 1400 runs goes from ~12 crashes to 0 with peers still converging.

This also removes the root cause of the empty-paragraph echo (facebook#8651): that
transient only arose downstream of one of these crashes, so it no longer occurs.

https://claude.ai/code/session_017fxm6RcQegBrGL2kXDJBo8
etrepum pushed a commit to etrepum/lexical that referenced this pull request Jun 9, 2026
…urrent-edit crashes, and update-tag desync (facebook#8651)

Hardens collaborative editing against three related failure modes, found
while building deterministic repros for facebook#8651 and then fuzzing the binding.

lexical-yjs:
- Avoid the empty-paragraph echo and the splice crash on collab undo. When
  a top-level paragraph collision empties the root, re-add the paragraph
  from outside the COLLABORATION/HISTORIC update so it syncs to peers
  (facebook#8651).
- Don't crash reconciling concurrent edits against removed nodes. When an
  event references a node that was concurrently removed (getNode() is null)
  or a shared type that was concurrently deleted (no __type), skip it
  gracefully instead of throwing an invariant ("could not find element
  node", "Expected shared type to include type attribute", ...) and
  corrupting the session.

lexical:
- Clear update tags after every commit, including no-op ones. Tags are
  added from the `tag` update option independent of dirtiness, and the
  'update' listener fires for every commit. Previously a no-op commit left
  its tags in editor._updateTags, where they leaked into the next update.
  In collaboration this silently dropped local edits: a remote sync that
  reconciled to nothing (e.g. it targeted a concurrently-removed node) left
  its COLLABORATION tag on the peer's next local edit, which
  syncLexicalUpdateToYjs then skipped -- so the edit never reached Yjs and
  the peer's live editor diverged from its own persisted document.
- This exposed a latent setEditorState bug: it cached _updateTags before an
  internal force-commit (which now resets it) and added the tag to the
  stale Set, dropping the tag from the applied state's commit and breaking
  history's undo classification. Read _updateTags fresh after the
  force-commit instead.

Tests (deterministic, exported-API repros; fail without the fixes):
- lexical-react: empty-paragraph echo repro; concurrent-reconcile crash
  repros; and a dropped-local-edit-after-no-op-remote-sync desync repro,
  all wiring peers over a mock async @lexical/yjs transport.
- lexical: a no-op update must not leak its tag into the next update, and
  setEditorState must keep its tag when committed from inside an update.

https://claude.ai/code/session_017fxm6RcQegBrGL2kXDJBo8
claude and others added 3 commits June 9, 2026 07:33
…urrent-edit crashes, and update-tag desync (facebook#8651)

Hardens collaborative editing against three related failure modes, found
while building deterministic repros for facebook#8651 and then fuzzing the binding.

lexical-yjs:
- Avoid the empty-paragraph echo and the splice crash on collab undo. When
  a top-level paragraph collision empties the root, re-add the paragraph
  from outside the COLLABORATION/HISTORIC update so it syncs to peers
  (facebook#8651).
- Don't crash reconciling concurrent edits against removed nodes. When an
  event references a node that was concurrently removed (getNode() is null)
  or a shared type that was concurrently deleted (no __type), skip it
  gracefully instead of throwing an invariant ("could not find element
  node", "Expected shared type to include type attribute", ...) and
  corrupting the session.

lexical:
- Clear update tags after every commit, including no-op ones. Tags are
  added from the `tag` update option independent of dirtiness, and the
  'update' listener fires for every commit. Previously a no-op commit left
  its tags in editor._updateTags, where they leaked into the next update.
  In collaboration this silently dropped local edits: a remote sync that
  reconciled to nothing (e.g. it targeted a concurrently-removed node) left
  its COLLABORATION tag on the peer's next local edit, which
  syncLexicalUpdateToYjs then skipped -- so the edit never reached Yjs and
  the peer's live editor diverged from its own persisted document.
- This exposed a latent setEditorState bug: it cached _updateTags before an
  internal force-commit (which now resets it) and added the tag to the
  stale Set, dropping the tag from the applied state's commit and breaking
  history's undo classification. Read _updateTags fresh after the
  force-commit instead.

Tests (deterministic, exported-API repros; fail without the fixes):
- lexical-react: empty-paragraph echo repro; concurrent-reconcile crash
  repros; and a dropped-local-edit-after-no-op-remote-sync desync repro,
  all wiring peers over a mock async @lexical/yjs transport.
- lexical: a no-op update must not leak its tag into the next update, and
  setEditorState must keep its tag when committed from inside an update.

https://claude.ai/code/session_017fxm6RcQegBrGL2kXDJBo8
@etrepum etrepum changed the title [lexical][lexical-yjs] Bug Fix: avoid empty-paragraph echo and splice crash on collab undo (#6614) Jun 9, 2026
@etrepum etrepum added this pull request to the merge queue Jun 9, 2026
Merged via the queue into facebook:main with commit d445826 Jun 9, 2026
78 of 79 checks passed
@etrepum etrepum mentioned this pull request Jun 25, 2026
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

3 participants