Skip to content

[lexical-yjs] Bug Fix: Map an equal subtree whole when collaboration v2 writes a local change - #9584

Merged
zurfyx merged 4 commits into
mainfrom
zurfyx/yjs-v2-equal-subtree-mapping
Oct 30, 2026
Merged

zurfyx merged 4 commits into
mainfrom
zurfyx/yjs-v2-equal-subtree-mapping

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Description

When the v2 binding writes a local change ($updateYFragment), it pairs the editor's children with the Y children from both ends. A child that is deep-equal to a Y child it is not mapped to ($equalYTypePNode), such as a table row equal to another row, was mapped to that Y type, but only at the top: its descendants kept their previous mappings, which can point at another subtree's nodes (the cells of an equal row). A later remote change to that row's children then read those cells and moved them into the wrong row. In #9581 one client's editor ends with a row that has no cells while its own document and the other clients have two; with other interleavings, a later read throws "Lexical node does not exist in active editor state", or the clients diverge. The same happens on main when one update removes a row's last cell and appends a new one (as padding a row does).

Now an equality match maps the whole subtree, child by child, as the comparison paired them (slots are equal only when already mapped to each other, so they need nothing more). The v1 binding does not use this code. The loop that compares the children left in the middle had the same gap for a text run equal to its Y text (a text node made again with the same text, between two links changed in the same update): it is mapped too.

Closes #9581

Changeset

.changeset/lexical-yjs-v2-equal-subtree-mapping.md (@lexical/yjs, [fix]).

Test plan

V2TableEqualRows.test.ts: the issue's three-peer script; 60 seeded runs of three v2 peers editing a table whose rows are equal (row and column inserts, a one-update remove-and-append of a row's last cell, partial delivery, each channel in order); and 108 cases that swap two equal blocks of each kind (paragraph, text runs, quote, list, nested list, table, inline mix of link, mark and line break, slot host, decorator), reached by the matching from the left, the right or the middle, in both client id orders, with gc on and off, then edit one of them. Each editor's whole state (its JSON) is compared with a fresh reader of the document and with the other peers.

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

Before

With SyncV2.ts as on main:

Error: Lexical node does not exist in active editor state (key 9869, type tablecell). The reference was probably kept from another read or update, for example in a closure, event handler or component
AssertionError: expected { root: { children: [ …(5) ], …(7) } } to deeply equal { root: { children: [ …(5) ], …(7) } }
      Tests  130 failed | 39 passed (169)

(The script, 57 of the 60 seeded runs, and 72 of the 108 swapped-block cases: every kind but the slot host and the decorator.)

After

      Tests  169 passed (169)

After the third commit (the text run in the middle loop; its new test fails without it with Lexical node does not exist in active editor state (key 39364, type text)):

      Tests  170 passed (170)

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

 Test Files  230 passed | 2 skipped (232)
      Tests  8053 passed | 46 expected fail | 4 skipped (8103)

pnpm exec vitest run --project unit packages/lexical-yjs after the second commit (whole-state checks, swapped blocks):

 Test Files  13 passed (13)
      Tests  408 passed | 4 expected fail (412)

pnpm run ci-check (each script): tsc, tsc-test, tsc-scripts, tsc-extension, tsc-website, flow, prettier, lint, check-changesets pass.

…v2 writes a local change

## Description

When the v2 binding writes a local change (`$updateYFragment`), it pairs the editor's children with the Y children from both ends. A child that is deep-equal to a Y child it is not mapped to (`$equalYTypePNode`), such as a table row equal to another row, was mapped to that Y type, but only at the top: its descendants kept their previous mappings, which can point at another subtree's nodes (the cells of an equal row). A later remote change to that row's children then read those cells and moved them into the wrong row. In #9581 one client's editor ends with a row that has no cells while its own document and the other clients have two; with other interleavings, a later read throws "Lexical node does not exist in active editor state", or the clients diverge. The same happens on main when one update removes a row's last cell and appends a new one (as padding a row does).

Now an equality match maps the whole subtree, child by child, as the comparison paired them (slots are equal only when already mapped to each other, so they need nothing more). The v1 binding does not use this code.

Closes #9581

## Changeset

`.changeset/lexical-yjs-v2-equal-subtree-mapping.md` (`@lexical/yjs`, `[fix]`).

## Test plan

`V2TableEqualRows.test.ts`: the issue's three-peer script, and 60 seeded runs of three v2 peers editing a table whose rows are equal (row and column inserts, a one-update remove-and-append of a row's last cell, partial delivery, each channel in order), each checked against a fresh reader of the document.

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

### Before

```
   × v2 peers: every editor shows what its own document holds after concurrent table edits 96ms
     × seed 1: every editor shows its own document, and the peers agree 21ms
     × seed 2: every editor shows its own document, and the peers agree 26ms
     × seed 3: every editor shows its own document, and the peers agree 11ms
Error: Lexical node does not exist in active editor state (key 419, type tablecell). The reference was probably kept from another read or update, for example in a closure, event handler or component
      Tests  58 failed | 3 passed (61)
```

### After

```
      Tests  61 passed (61)
```

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

```
 Test Files  230 passed | 2 skipped (232)
      Tests  8053 passed | 46 expected fail | 4 skipped (8103)
```

`pnpm run ci-check` (each script): tsc, tsc-test, tsc-scripts, tsc-extension, tsc-website, flow, prettier, lint, check-changesets pass.
@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 2:43pm UTC
lexical-playground Ready Ready Preview Oct 30, 2026 2: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
@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:20 PM UTC

@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: ad08474

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

Thanks for the focused fix and the clear reproduction. $mapEqualSubtree pairs nodes exactly as $equalYTypePNode compared them, CollabV2Mapping.set then drops the stale cross-row entries, and the new test fails on main and passes here.

A few suggestions, none blocking:

  1. The same stale mapping remains in the compare loop (packages/lexical-yjs/src/SyncV2.ts:1137-1141). When the loop meets a text run equal to its Y text, nothing updates the mapping. To reproduce with two v2 peers:

    • Write a paragraph link("a"), "middle", link("c").
    • In one update, change both links' URLs and replace "middle" with a new text node holding the same text.
    • Have the other peer type into "middle".

    The first client throws Lexical node does not exist in active editor state and keeps showing "middle" while its document holds "middleX". This predates the PR, but it is the same class of bug. Adding binding.mapping.set(leftY, leftL) when the text is equal fixes it, and the package's unit tests still pass. It could go in this PR or in a follow-up.

  2. Assert the whole editor state in the test (packages/lexical-yjs/src/__tests__/unit/V2TableEqualRows.test.ts:185-193). rows() compares only the cell count of each row, so content that lands in the wrong row with the same counts would still pass. Comparing editor.getEditorState().toJSON() with the fresh reader's also passes with this fix.

  3. Add // @vitest-environment node after the copyright header of the new test. AGENTS.md (Testing Strategy) asks for it in unit tests that only exercise editor state or headless behavior, and this test needs no DOM.

  4. Broaden the changeset wording (.changeset/lexical-yjs-v2-equal-subtree-mapping.md, lines 5 and 7). The fix covers any block that equals another: list items with equal text hit the same error on main. The entry mentions only table rows, and .changeset/README.md asks for entries written for people who use Lexical, so naming the general case (for example "table rows or list items") would help them find it.

… tests, add swapped equal blocks of each kind, and dispose the test editors

## Description

The tests for #9581 compared only the number of cells per table row, which would not show a wrong payload, format or list state. Every check now compares each editor's whole state (its JSON) with a fresh reader of the document and with the other peers. A third group swaps two equal blocks of each kind (paragraph, text runs with formats, a tab, a hashtag and a code token, quote, list, nested list, table, link, mark and line break, a slot host, a decorator), with the lines around them changed or not so that the matching reaches them from the left, from the right or in the middle, in both client id orders, with and without gc, then has a peer edit the first of them. Each test disposes its editors and documents when it finishes.

## Test plan

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

### Before

With `SyncV2.ts` as on main:

```
Error: Lexical node does not exist in active editor state (key 9869, type tablecell). The reference was probably kept from another read or update, for example in a closure, event handler or component
AssertionError: expected { root: { children: [ …(5) ], …(7) } } to deeply equal { root: { children: [ …(5) ], …(7) } }
      Tests  130 failed | 39 passed (169)
```

(The scripted case, 57 of the 60 seeded runs, and 72 of the 108 swapped-block cases: every kind but the slot host and the decorator.)

### After

```
      Tests  169 passed (169)
```

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

```
 Test Files  13 passed (13)
      Tests  408 passed | 4 expected fail (412)
```

`pnpm run tsc-test`, `pnpm run lint` and `pnpm run prettier` pass.
… middle of a block's children

## Description

When collaboration v2 writes a local change, the loop that compares the children left between the matched start and end (`$updateYFragment`) left a text run that equals its Y text unmapped. A text node made again with the same text there (between two links whose URLs changed in the same update) kept the mapping of the node it replaced, and a collaborator's edit of that text then failed with "Lexical node does not exist in active editor state" while the editor kept showing the old text. That run is now mapped as the start and end loops map an equal child.

Also: the test file runs in the node environment, and the changeset names the general case (table rows or list items with equal content, a text node made again with the same text).

## Test plan

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

### Before

Without the `SyncV2.ts` change in this commit:

```
   × v2 peers: a text run made again with the same text, between two changed links, keeps its mapping 14ms
Error: Lexical node does not exist in active editor state (key 39364, type text). The reference was probably kept from another read or update, for example in a closure, event handler or component
      Tests  1 failed | 169 passed (170)
```

### After

```
      Tests  170 passed (170)
```

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

```
 Test Files  54 passed (54)
      Tests  689 passed | 4 expected fail (693)
```

`pnpm run tsc`, `tsc-test`, `flow`, `check-changesets`, `lint` and `prettier` pass.
@zurfyx

zurfyx commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Review follow-ups:

  1. Compare loop: fixed in 66b2bfa (the equal text run in the middle loop is mapped, with your reproduction as a test: it throws Lexical node does not exist in active editor state without the change, passes with it).
  2. Whole editor state: done in 4c52086 (every check compares each editor's JSON with a fresh reader and the other peers; plus 108 cases swapping equal blocks of each kind).
  3. // @vitest-environment node: added in 66b2bfa.
  4. Changeset: now names table rows or list items with equal content and a text node made again with the same text (66b2bfa).

@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. $mapEqualSubtree maps each descendant exactly as $equalYTypePNode paired it, CollabV2Mapping.set then drops the stale entries that pointed into an equal sibling, and the new tests fail on main and pass here while comparing every editor's whole state with a fresh reader and with the other peers. Thanks for also covering the equal text run in the compare loop.

Two optional, non-blocking notes:

  1. Changeset wording (.changeset/lexical-yjs-v2-equal-subtree-mapping.md, line 5). The summary ends with after a collaborator's concurrent edit, but the edit does not have to be concurrent: in the swapped-block tests, A's swap reaches B before B edits, and those cases still diverge on main. .changeset/README.md asks for an entry that describes the change from the user's side, so after a collaborator's later edit would match what people see.

  2. A separate follow-up, not for this PR. With randomized three-peer edits over equal table rows, list items and paragraphs, this branch converges where main usually does not, and the one remaining divergence I found has another cause: when a remote update removes a list's last item, the receiving editor can drop the ListNode while the Y document keeps an empty list element. A later remote change then throws Lexical node does not exist in active editor state for the list, or the editors end without the empty list their documents hold. It may deserve its own issue.

…ubtree changeset

## Description

The changeset said the wrong content or the error appeared after a collaborator's concurrent edit, but the edit does not have to be concurrent: in the swapped-block tests, the swap reaches the other client before it edits, and those cases fail on main too. It now says "a collaborator's later edit".

## Test plan

```
pnpm run check-changesets
```

### Before

A wording change; the check passed before it too.

### After

```
Changeset check passed: 32 pending changeset(s) valid.
```
@zurfyx

zurfyx commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Thanks. (1) Changeset now says "a collaborator's later edit" (ad08474, wording only). (2) The list case (a remote update removing a list's last item drops the ListNode while the Y document keeps an empty list element) is out of scope here; it is being written up separately.

@zurfyx

zurfyx commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

The list case from the review is now #9592 (a v2 client with a caret drops a list that remote removals empty, while the document keeps it; a later change to the list throws). It reproduces on main and on this branch, so it stays out of this PR.

@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, thanks for the thorough fix and tests.

  • The mapping walk matches the comparison. $mapEqualSubtree (packages/lexical-yjs/src/SyncV2.ts:804-825) pairs the i-th Y child with the i-th normalized Lexical child, which is exactly how $equalYTypePNode (:768-802) compared them, and CollabV2Mapping.set drops each node's earlier association. That removes the stale descendant entries that let a later remote change splice another row's cells, or a removed node, into a block. Slots need no walk because $equalSlots only accepts slots that are already mapped to each other, and the compare-loop branch (:1137-1142) now maps an equal text run the same way $updateYText maps an unequal one.
  • No protocol impact. None of the three changed branches writes to the Y document, so existing documents and peers on older versions are unaffected.
  • The tests separate the base from the fix. The new tests fail against the base SyncV2.ts (131 of 170: the issue's script, 57 of the 60 seeds, 72 of the 108 swapped-block cases and the text-run case) and pass on this branch, matching the Before and After in the description. Every check compares whole editor states with a fresh reader of the document and with the other peers, rather than only cell counts.
  • Changeset. .changeset/lexical-yjs-v2-equal-subtree-mapping.md follows .changeset/README.md and CONTRIBUTING.md: a patch bump, a one-line [fix] summary and a Verify: paragraph.

Thanks also for splitting the empty-list case out into #9592; it is a separate bug and does not need to block this one. No changes needed from my side.

@zurfyx
zurfyx added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 0c20530 Oct 30, 2026
57 checks passed
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
```
zurfyx added a commit that referenced this pull request Oct 30, 2026
…and count each id's holders in the HTML import check

## Description

#9540 and #9584 merged, so the cells marked `test.fails` for them pass on main and report as passing unexpectedly: letters composed past the end of a marked run (26 cells in `MarksIME.test.ts`) and the `@lexical/yjs` v2 table row cell of #9581 (`C6Settles.test.ts`, `yjs-v2, table edits, follow-ups none, repair none, seed 11`). They are plain tests again. The marks for #9553, #9568, #9578 (and #9561) and #9606 stay.

The import check of `C7Html.test.ts` collected the imported blocks' ids in a map, so two blocks holding the same id counted as one and "exactly one imported block" was never asserted. It now keeps every holder and requires exactly one, of the same type.

## Test plan

```
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix
pnpm run ci-check
```

### Before

On main `04e899d` with the marks:

```
 ❯ packages/lexical-y/src/__tests__/unit/matrix/MarksIME.test.ts (56 tests | 26 failed)
 ❯ packages/lexical-y/src/__tests__/unit/matrix/C6Settles.test.ts (360 tests | 1 failed)
```

### After

On main `04e899d`:

```
 Test Files  13 passed (13)
      Tests  2395 passed | 13 expected fail (2408)

$ pnpm run ci-check
Found 0 errors
```

This branch was successfully deployed

2 active deployments
Preview – lexical — ad08474c Deployed Oct 30, 2026 by vercel[bot]
Preview – lexical-playground — ad08474c 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 v2 collaboration client shows a table row without cells after concurrent row and column inserts

1 participant