Skip to content

[lexical-yjs] Bug Fix: Keep an element that collaboration v2 empties, as the document keeps it - #9595

Closed
zurfyx wants to merge 2 commits into
mainfrom
zurfyx/yjs-v2-keep-emptied-element
Closed

zurfyx wants to merge 2 commits into
mainfrom
zurfyx/yjs-v2-keep-emptied-element

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Description

When remote changes leave an element with no children (two collaborators each remove one item of a two-item list at the same time), syncYjsChangesToLexicalV2__EXPERIMENTAL removed the old children with ElementNode#splice. With a range selection, splice also removes an element that cannot be empty (ListNode#canBeEmpty is false), so an editor with a caret dropped the list, while the Y document keeps the empty list element and the binding kept it mapped to the removed node. That editor then differed from a fresh reader of its document, and a later change to the list (an item a collaborator adds to it) failed with "Lexical node does not exist in active editor state". Editors without a selection kept the empty list.

Now, when the Y element holds no children any more, the old children are removed one by one with the element kept, as the document keeps it, whatever the selection. When that element cannot be empty, a selection point left in it moves beside it, where Lexical moves a point out of a node it removes (as on main, where the list was removed): otherwise typing there would put a text node straight into the list on that client, which the other clients read as a list item.

Closes #9592

Changeset

.changeset/lexical-yjs-v2-keep-emptied-element.md (@lexical/yjs, [fix]).

Test plan

V2EmptiedList.test.ts: the issue's three-client case, with the receiving editor's selection after the list, in the first item, in the second item, across the list, or none. Each checks the receiver against a fresh reader of its document, then has a collaborator add an item to the empty list and checks every client against it.

pnpm exec vitest run --project unit packages/lexical-yjs/src/__tests__/unit/V2EmptiedList.test.ts

Before

   × v2: a list emptied by two clients' removals, the receiver with a caret after the list
   × v2: a list emptied by two clients' removals, the receiver with a caret in the first item
   × v2: a list emptied by two clients' removals, the receiver with a caret in the second item
   × v2: a list emptied by two clients' removals, the receiver with a selection across the list
   ✓ v2: a list emptied by two clients' removals, the receiver with no selection
AssertionError: expected { root: { children: [ …(2) ], …(7) } } to deeply equal { root: { children: [ …(3) ], …(7) } }
      Tests  4 failed | 1 passed (5)

After

After the second commit (caret moved beside the emptied element; each case in both client id orders; the receiver types after the removals and every client and a fresh reader agree): Tests 10 passed (10) (the first commit fails the 4 caret-in-item cases; main fails 8).

      Tests  5 passed (5)

pnpm exec vitest run --project unit packages/lexical-yjs packages/lexical-react/src/__tests__/unit:

 Test Files  54 passed (54)
      Tests  524 passed | 4 expected fail (528)

pnpm run ci-check (each script): tsc, tsc-test, tsc-scripts, tsc-extension, tsc-website, flow, prettier, lint, check-changesets pass; eslint --no-cache packages/lexical-yjs passes.

… as the document keeps it

## Description

When remote changes leave an element with no children (two collaborators each remove one item of a two-item list at the same time), `syncYjsChangesToLexicalV2__EXPERIMENTAL` removed the old children with `ElementNode#splice`. With a range selection, `splice` also removes an element that cannot be empty (`ListNode#canBeEmpty` is false), so an editor with a caret dropped the list, while the Y document keeps the empty list element and the binding kept it mapped to the removed node. That editor then differed from a fresh reader of its document, and a later change to the list (an item a collaborator adds to it) failed with "Lexical node does not exist in active editor state". Editors without a selection kept the empty list.

Now, when the Y element holds no children any more, the old children are removed one by one with the element kept, as the document keeps it, whatever the selection.

Closes #9592

## Changeset

`.changeset/lexical-yjs-v2-keep-emptied-element.md` (`@lexical/yjs`, `[fix]`).

## Test plan

`V2EmptiedList.test.ts`: the issue's three-client case, with the receiving editor's selection after the list, in the first item, in the second item, across the list, or none. Each checks the receiver against a fresh reader of its document, then has a collaborator add an item to the empty list and checks every client against it.

```
pnpm exec vitest run --project unit packages/lexical-yjs/src/__tests__/unit/V2EmptiedList.test.ts
```

### Before

```
   × v2: a list emptied by two clients' removals, the receiver with a caret after the list
   × v2: a list emptied by two clients' removals, the receiver with a caret in the first item
   × v2: a list emptied by two clients' removals, the receiver with a caret in the second item
   × v2: a list emptied by two clients' removals, the receiver with a selection across the list
   ✓ v2: a list emptied by two clients' removals, the receiver with no selection
AssertionError: expected { root: { children: [ …(2) ], …(7) } } to deeply equal { root: { children: [ …(3) ], …(7) } }
      Tests  4 failed | 1 passed (5)
```

### After

```
      Tests  5 passed (5)
```

`pnpm exec vitest run --project unit packages/lexical-yjs packages/lexical-react/src/__tests__/unit`:

```
 Test Files  54 passed (54)
      Tests  524 passed | 4 expected fail (528)
```

`pnpm run ci-check` (each script): tsc, tsc-test, tsc-scripts, tsc-extension, tsc-website, flow, prettier, lint, check-changesets pass; `eslint --no-cache packages/lexical-yjs` passes.
@vercel

vercel Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
lexical Ready Ready Preview Oct 30, 2026 5:03pm UTC
lexical-playground Ready Ready Preview Oct 30, 2026 5:03pm UTC

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 Oct 30, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9595

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9595

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9595

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9595

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9595

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9595

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9595

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9595

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9595

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9595

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9595

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9595

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9595

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9595

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9595

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9595

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9595

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9595

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9595

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9595

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9595

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9595

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9595

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9595

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9595

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9595

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9595

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9595

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9595

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9595

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9595

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9595

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9595

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9595

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9595

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9595

commit: bf3cbaa

@meta-code-review

meta-code-review Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🔄 In progress (for maintainers only) Oct 30, 2026, 5:03 PM UTC

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks good to me. Keeping the emptied element matches what the Y document holds, so the receiving editor now agrees with a fresh reader of the document, which is also how collab v1 behaves (CollabElementNode.syncChildrenFromYjs detaches removed children with $removeFromParent and keeps the parent). The new test fails 4 of its 5 cases on main (cad90155) and passes all 5 here. The new branch also runs whenever a collaborator deletes all of a paragraph's text, and there the receiver's document and caret come out the same as on main; the same holds when remote removals empty the root.

Optional, non-blocking suggestions:

  1. packages/lexical-yjs/src/__tests__/unit/V2EmptiedList.test.ts:229: the cases with a caret in the list don't assert where B's caret ends up. With this change it lands at offset 0 inside the now-empty ListNode (an element point), and typing there adds a list item that reaches every peer. Asserting that point after b.sync(c), or adding an insertText step, would pin the selection repair this fix relies on.
  2. packages/lexical-yjs/src/SyncV2.ts:365: child.remove(true) goes through each node's remove() override, which the splice path never did. The only override in the repository, ListItemNode.remove, merges nested lists only when both siblings exist, so it cannot fire when children are removed first to last; a few words in the comment would save the next reader that check.

// B's editor shows what its document holds: an empty list.
const fresh = new Peer(99);
fresh.sync(b);
expect(b.json()).toEqual(fresh.json());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional, non-blocking: the cases with a caret in the list don't assert where B's caret ends up. With this change it lands at offset 0 inside the now-empty ListNode (an element point), and typing there adds a list item that reaches every peer. Asserting that point here (or adding an insertText step on B) would pin the selection repair the fix relies on.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, this found a real gap. The caret did land at offset 0 in the empty ListNode, but typing there put a text node straight into the list on that client, while the other clients read the stored text as a list item, so they diverged. Fixed in bf3cbaa: a selection point left in an emptied element that cannot be empty moves beside it, where Lexical moves a point out of a node it removes (as main did when it dropped the list). The test now asserts the caret ends at the end of the paragraph before the list and has B type there, checking every client and a fresh reader; each case runs in both client id orders.

// remove an element that cannot be empty, such as a list, and the Y
// element would stay mapped to a removed node.)
for (const child of node.getChildren()) {
child.remove(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional, non-blocking: remove(true) dispatches to each child's remove() override, which the splice path never did. The only override in the repository, ListItemNode.remove, merges nested lists only when both siblings exist, so it cannot fire when children are removed first to last. A few words in the comment above would save the next reader that check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in bf3cbaa: the comment now says that remove() runs each child's override and why ListItemNode's nested-list merge cannot fire when children are removed first to last.

…2 empties, where removing it would have moved it

## Description

With the first commit, an editor whose caret was in a list item that remote removals deleted kept the empty list, as the document does, but its caret stayed in the empty list (an element point at offset 0). Typing there put a text node straight into the `ListNode` on that client, while the other clients read the stored text as a list item, so they diverged. On main this did not arise because the list was dropped and Lexical moved the caret out of it.

Now, when the emptied element cannot be empty, a selection point left in it moves beside it, exactly where Lexical moves a point out of a node it removes: to the end of its previous sibling, else the start of its next sibling, else its parent at its index. The comment also notes why running each child's `remove()` override is safe here (`ListItemNode`'s merges nested lists only when an item has siblings on both sides, which never holds when children are removed first to last).

## Test plan

`V2EmptiedList.test.ts` now runs each receiver selection in both client id orders, checks that a caret that was in a removed item ends at the end of the paragraph before the list, and then has the receiver type where its selection is and checks every client and a fresh reader against it.

```
pnpm exec vitest run --project unit packages/lexical-yjs/src/__tests__/unit/V2EmptiedList.test.ts
```

### Before

On the first commit (7b00d5d):

```
   × v2: a list emptied by two clients' removals, the receiver with a caret in the first item (client ids 1,2,3)
   × v2: a list emptied by two clients' removals, the receiver with a caret in the first item (client ids 3,2,1)
   × v2: a list emptied by two clients' removals, the receiver with a caret in the second item (client ids 1,2,3)
   × v2: a list emptied by two clients' removals, the receiver with a caret in the second item (client ids 3,2,1)
AssertionError: expected '' to be 'before' // Object.is equality
      Tests  4 failed | 6 passed (10)
```

On main: `Tests  8 failed | 2 passed (10)`.

### After

```
      Tests  10 passed (10)
```

`pnpm exec vitest run --project unit packages/lexical-yjs packages/lexical-react/src/__tests__/unit`:

```
 Test Files  54 passed (54)
      Tests  529 passed | 4 expected fail (533)
```

`pnpm run ci-check` (each script, run one at a time): tsc, tsc-test, tsc-scripts, tsc-extension, tsc-website, flow (`Found 0 errors`), prettier (no files listed), lint, check-changesets pass; `eslint --no-cache packages/lexical-yjs` passes.
@zurfyx
zurfyx added this pull request to the merge queue Oct 30, 2026
@zurfyx
zurfyx removed this pull request from the merge queue due to a manual request Oct 30, 2026
@zurfyx

zurfyx commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Closing without merging: @lexical/yjs collaboration v1 and v2 are deprecated in favour of @lexical/y, and fixes to the legacy bindings are not planned (decision register D12 in #9594). The test and the analysis stay here for anyone maintaining a fork.

@zurfyx zurfyx closed this Oct 30, 2026
zurfyx added a commit that referenced this pull request Oct 30, 2026
…ote the v1/v2 fixes merged and closed

## Description

Two rows of the `@lexical/y` decision register did not match the code or the pull request history:

- D72 said the binding's first read is not deferred until every extension registers. The binding starts in `YCollabExtension`'s `afterRegistration`, once every extension has registered (the `TableOnOpen` test comment #9537 corrects says the same). The row now says that, and that the decision is no further deferral or special-casing: an initialization-order bug is fixed in the extension that has it, as #9501 did.
- D12 (no more fixes to `@lexical/yjs` v1 and v2) now notes that one v2 table fix, #9584, merged on 10-30 before the row was checked, and that later v1 and v2 fix pull requests, #9595 and #9596, were closed under it.

## Changeset

None: documentation only.

## Test plan

### Before

D72 contradicted `packages/lexical-y/src/internal/YExtension.ts`, which starts the binding in `afterRegistration`.

### After

```
$ pnpm run ci-check
...
Changeset check passed: 31 pending changeset(s) valid.
...
Found 0 errors
```

This branch was successfully deployed

2 active deployments
Preview – lexical — bf3cbaa4 Deployed Oct 30, 2026 by vercel[bot]
Preview – lexical-playground — bf3cbaa4 Deployed Oct 30, 2026 by vercel[bot]
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.

Projects

None yet

1 participant