Skip to content

[lexical-yjs] Bug Fix: Keep a v1 editor showing what its document holds when collaborators' text lands where no text node is - #9596

Closed
zurfyx wants to merge 1 commit into
mainfrom
zurfyx/fix-9583-v1-orphaned-text
Closed

zurfyx wants to merge 1 commit into
mainfrom
zurfyx/fix-9583-v1-orphaned-text

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 30, 2026

Copy link
Copy Markdown
Member

Description

Three v1 clients share before [commented] after, commented in a mark.
B wraps the o in a mark c1 and types x in the rest (mmented); C,
having seen only B's first change, wraps the first m of mmented in
c1, which writes mmented again. Once everything is delivered, no
editor matches a fresh read of its own document, and the clients
disagree (#9583).

B's x lands in C's document between C's new mark and the deleted old
text, where no text node is. applyChildrenYjsDelta removes such text
from the shared type ("ignore the text"), but the removal was read back
as a remote change and applied to collab children that never held the
text, so it deleted the child now at that index: C's new mark, which its
document kept. On the clients that had x inside their text node, C's
change then deleted that node's properties map, and the dangling-text
heuristic removed the leftover text at an offset into the text node
rather than its index in the element, deleting other text (c and the
map before it).

Both places now mark the element instead, and once its deltas are applied
settleOrphanedText removes the text no text node holds (text not after
a text node's properties map) in one transaction with the binding as its
origin, so it is not read back, and builds the element's collab children
again from the shared type. The text is still removed, as before: a v1
text node needs its properties map, and every client removes the same
items.

Closes #9583

Test plan

packages/lexical-yjs/src/__tests__/unit/NestedMarksConcurrentTyping.test.ts
(new): the issue's steps with three v1 clients wired like the React
collaboration plugin, for a mark at depth 1 and 2, with B or C the client
that marks and types, in two client id orders: every editor equals a fresh
read of its own document, and the clients agree.

Before

$ pnpm exec vitest run --project unit packages/lexical-yjs/src/__tests__/unit/NestedMarksConcurrentTyping.test.ts
      Tests  8 failed (8)
AssertionError: A equals its own document: expected { root: { …(8) } } to deeply equal { root: { …(8) } }

After

$ pnpm exec vitest run --project unit packages/lexical-yjs/src/__tests__/unit/NestedMarksConcurrentTyping.test.ts
      Tests  8 passed (8)
$ pnpm exec vitest run --project unit packages/lexical-yjs packages/lexical-react
 Test Files  54 passed (54)
      Tests  527 passed | 4 expected fail (531)

pnpm run ci-check passes.

Changeset

[fix] Keep a v1 collaboration editor showing what its document holds when text a collaborator typed lands where no text node is, such as inside nested marks marked concurrently

The v1 binding removes such text from the document, as before, but that removal was read back as a remote change and deleted the node at its index instead (a mark next to the text vanished from the editor while its document kept it), and a text node whose properties were deleted in a merge had its leftover text removed at an offset into the node rather than its index in the element, deleting other text. The text is now removed with the binding as the origin, at its index, and the element's children are read again from the document.

Verify: with three v1 clients on a paragraph whose middle word is commented, mark a letter of the word and type after it on one client, mark the next letter on another that saw only the first mark, deliver everything, and check that every editor matches a fresh read of its own document.

…ds when collaborators' text lands where no text node is

## Description

Three v1 clients share `before [commented] after`, `commented` in a mark.
B wraps the `o` in a mark `c1` and types `x` in the rest (`mmented`); C,
having seen only B's first change, wraps the first `m` of `mmented` in
`c1`, which writes `mmented` again. Once everything is delivered, no
editor matches a fresh read of its own document, and the clients
disagree (#9583).

B's `x` lands in C's document between C's new mark and the deleted old
text, where no text node is. `applyChildrenYjsDelta` removes such text
from the shared type ("ignore the text"), but the removal was read back
as a remote change and applied to collab children that never held the
text, so it deleted the child now at that index: C's new mark, which its
document kept. On the clients that had `x` inside their text node, C's
change then deleted that node's properties map, and the dangling-text
heuristic removed the leftover text at an offset into the text node
rather than its index in the element, deleting other text (`c` and the
map before it).

Both places now mark the element instead, and once its deltas are applied
`settleOrphanedText` removes the text no text node holds (text not after
a text node's properties map) in one transaction with the binding as its
origin, so it is not read back, and builds the element's collab children
again from the shared type. The text is still removed, as before: a v1
text node needs its properties map, and every client removes the same
items.

Closes #9583

## Test plan

`packages/lexical-yjs/src/__tests__/unit/NestedMarksConcurrentTyping.test.ts`
(new): the issue's steps with three v1 clients wired like the React
collaboration plugin, for a mark at depth 1 and 2, with B or C the client
that marks and types, in two client id orders: every editor equals a fresh
read of its own document, and the clients agree.

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-yjs/src/__tests__/unit/NestedMarksConcurrentTyping.test.ts
      Tests  8 failed (8)
AssertionError: A equals its own document: expected { root: { …(8) } } to deeply equal { root: { …(8) } }
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-yjs/src/__tests__/unit/NestedMarksConcurrentTyping.test.ts
      Tests  8 passed (8)
$ pnpm exec vitest run --project unit packages/lexical-yjs packages/lexical-react
 Test Files  54 passed (54)
      Tests  527 passed | 4 expected fail (531)
```

`pnpm run ci-check` 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 3:43pm UTC
lexical-playground Ready Ready Preview Oct 30, 2026 3:43pm 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@9596

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 09e5d9a

@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
🟢 Reviewed (for maintainers only) Oct 30, 2026, 5:40 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.

This looks good to me. Removing orphaned text once all deltas are applied, in a transaction whose origin is the binding, and then rebuilding the element's collab children from the shared type addresses both halves of #9583: the removal is no longer read back against children that never held the text, and the dangling-text case no longer deletes at an offset into the text node. The new test reproduces the issue for both mark depths, with either client typing and in both client-id orders.

A few optional, non-blocking suggestions:

  1. One orphan path still bypasses settleOrphanedText (packages/lexical-yjs/src/CollabElementNode.ts, lines 327-357). When a remote embed lands inside a text node and no new text node follows it in the same delta, the split-off tail held in pendingSplitText stays in the shared type right after the embed with no properties map before it, but orphaned is never set. That client's later deltas for the element are then indexed against children that are short by the tail's length. For example, with three clients: B removes the c1 mark around f, so A holds one text node ybfore x; C, without B's change, marks the b; on A, fore x is left orphaned after C's new mark, and the next character C types lands at the start of a later text node in A's editor but not in A's document. Setting orphaned = true when pendingSplitText !== null after the loop lets the same settle handle it. The base has the same gap, so a follow-up is fine too.
  2. Assert the converged content in the test (packages/lexical-yjs/src/__tests__/unit/NestedMarksConcurrentTyping.test.ts, lines 246-255). The test checks that each editor matches its own document and that the clients agree, but not what they agree on, so a regression that dropped the same text on every client would still pass. All eight cases converge to before commented after, so expect(a.editor.read(() => $getRoot().getTextContent())).toBe('before commented after') pins it.
  3. Add // @vitest-environment node after the copyright header of the new test. It only exercises headless editor state, which is the case AGENTS.md ("Testing Strategy") asks the annotation for, and most sibling lexical-yjs unit tests carry it.

throw new Error('Unexpected delta format');
}
}
if (orphaned) {

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: an unconsumed pendingSplitText is a third orphan case that never reaches this check. When a remote embed lands inside a text node and no text node follows it in the same delta (lines 327-350), the tail stays in the shared type right after the embed with no properties map before it, so later deltas for this element are indexed against children that are short by its length and land in the wrong text node. Flagging it lets settleOrphanedText handle it the same way (the base has the same gap, so a follow-up is fine too):

Suggested change
if (orphaned) {
if (pendingSplitText !== null) {
orphaned = true;
}
if (orphaned) {

).toEqual(fresh.json());
}
expect(b.json()).toEqual(a.json());
expect(c.json()).toEqual(a.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: this checks that the clients agree with their documents and with each other, but not what they agree on, so a regression that dropped the same text on every client would still pass. All eight cases converge to before commented after (B's x removed), so pinning it is cheap:

Suggested change
expect(c.json()).toEqual(a.json());
expect(c.json()).toEqual(a.json());
expect(a.editor.read(() => $getRoot().getTextContent())).toBe(
'before commented after',
);

* LICENSE file in the root directory of this source tree.
*
*/

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 nit: this file only exercises headless editor state, so AGENTS.md ("Testing Strategy") asks for // @vitest-environment node after the copyright header, as most sibling lexical-yjs unit tests have:

Suggested change
// @vitest-environment node

@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 — 09e5d9a3 Deployed Oct 30, 2026 by vercel[bot]
Preview – lexical-playground — 09e5d9a3 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

Development

Successfully merging this pull request may close these issues.

Bug: [lexical-yjs] A v1 collaboration client loses text inside nested marks after concurrent marking and typing

1 participant