Skip to content

[lexical-website][lexical-y] Bug Fix: Write a block reorder as a move of the block Lexical moved - #9502

Merged
zurfyx merged 3 commits into
mainfrom
zurfyx/y-reorder-intent
Oct 29, 2026
Merged

zurfyx merged 3 commits into
mainfrom
zurfyx/y-reorder-intent

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Note

Stack, part 6 of 10. Based on #9508 (zurfyx/y-initial-state-binding). Review only this PR's own commits.

The @lexical/y fixes touch the same writer, cursor, reader and binding internals, so they are one linear stack: each PR is based on the one below it, each conflict is resolved once, in the PR whose change causes it, and every CI run tests the real combination.

Merge order: bottom first, #9509 → #9523 → #9522 → #9505 → #9508 → #9502 → #9504 → #9527 → #9507 → #9510. With squash merges, rebase the next PR onto main before merging it (git rebase --onto main <old base branch>).

The stack
# PR Branch Base
1 #9509 zurfyx/update-error-rollback main
2 #9523 zurfyx/write-before-unread-change #9509
3 #9522 zurfyx/y-keep-chars-at-format-boundary #9523
4 #9505 zurfyx/y-undo-member-closer #9522
5 #9508 zurfyx/y-initial-state-binding #9505
6 #9502 (this PR) zurfyx/y-reorder-intent #9508
7 #9504 zurfyx/y-preserve-unknown-attrs #9502
8 #9527 zurfyx/y-move-keeps-unknown-fields #9504
9 #9507 zurfyx/y-composition-holdback #9527
10 #9510 zurfyx/y-incremental-read-marks #9507

Follow-ups for after the stack (not in it):

Description

Yjs has no moves, so when a commit reorders blocks, @lexical/y keeps some blocks in place and deletes and re-inserts the others. Whatever a collaborator types in a re-inserted block at the same time is lost or stays at its old position. The writer chose the blocks to keep by block count and then by content size, and used Lexical's record of explicit moves (EditorState#_movedNodes) only to break exact ties. So a reorder was written as a move of whichever side held less content, not of the block the user moved:

  • Peer A moves a two-item list above the paragraph Another line (paragraph.insertBefore(list), as a block drag does) while peer B types X after Another. Every client and a fresh reader end with list("Xitem one", "item two") / paragraph("Another line"): B's text lands in a block nobody moved.
  • Without concurrency, B's caret after Another jumps to the start of item one when A's move arrives.
  • A table (1x2 or 2x2) or a list with more text than the paragraph, moved up or down past it, leaves B's X in a paragraph of its own at the paragraph's old place.

A short list, a quote or a paragraph moved the same way only passed because each held less content than the paragraph.

With this change, the block Lexical recorded as moved is the one rewritten, which is what @lexical/yjs already does (getStableChildKeys). Writer#$writeStructure splits the matched units into those the commit moved and the rest. A unit counts as moved when its node, or an element between it and the container being written (the list of a list item, the item that holds a nested list), is in the record. The rest keep their relative order, since only a move changes it, so all of them stay in place. A moved unit stays too where it is still in order between them (put back where it was, or indented). Block count and size now only decide where Lexical recorded nothing (an editor state set whole) and among moved units. Core already records every move made with insertBefore, insertAfter, append or splice, so it is unchanged. The stored layout does not change.

With a concurrent move of a two-item list Before After
B types in the paragraph the list passes lands in item one stays in the paragraph
B's caret in that paragraph jumps into item one stays
B types in a paragraph a moved table passes lands in a paragraph of its own stays
B types in the moved list itself stays in the list stays at the list's old place

The last row is the documented Moves limit, which before applied only when the moved block was the smaller one. It covers text typed in the moved block at the same time, a collaborator's caret in it, and what a collaborator typed in it before the mover undoes the move. Two BlocksAndLines tests asserted the old trade-off (typing inside a dragged collapsible, and inside a paragraph moved down one slot, survived because the passed block was rewritten). They are now test.fails, next to new tests for typing in the block passed. The y.md Moves limit now says which block counts as moved, the fallback, and the undo case.

These are left as expected failures in regressions/Moves.test.ts:

  • the moved block itself (above);
  • a node put back where it was in the same update as a move (an insert next to the sibling it already has, a remove and a re-insert, a move away and back). Lexical's record counts such a node as moved too, so when a real move passes it, the smaller of the two is rewritten. Refining that record would be a core change shared with @lexical/yjs.

Also in this PR: two concurrent joins into one line (second commit)

Take the blocks [heading, quote, paragraph]. Peer A deletes from the end of the heading to the start of the paragraph, across the quote, while peer B joins the paragraph into the quote. With one client id order, every client ends with the heading's and the paragraph's text in a quote (a code block too), although neither edit retyped the first line. A join deletes the earlier line's marker and writes the earlier line's type onto the later line's marker. Here both joins write the paragraph's marker, and Yjs keeps one of the two writes by client id. The type is one value on that marker, so the two writes cannot both be kept; keeping the earlier line's type would need each join to record which line it came from and a follow-up to elect one. The second commit records the race as a known limit (regressions/Joins.test.ts, with test.fails for the losing order, and a sentence under y.md Limits, Other races) and changes nothing stored. It is independent of the reorder fix: a join reorders nothing, and the new tests give the same results with and without it.

Also in this PR: the self-check of a container a move gave new storage (third commit)

With the reorder fix, when one update first changes the content of a container inside the block it moves (a list given an item, then moved into a box that moves too), the writer writes the box's container in place and then writes the box anew for its move. The document is right, but the development self-check compared the box's old, deleted container with the editor and warned. It now compares only containers that are still their node's. New tests: a block written, then moved, in one update in regressions/Moves.test.ts (4 tests, which fail on the first two commits).

Restack (October 29)

Rebased onto #9508 with no conflicts (it was based on a main one commit older).

Changeset

.changeset/lexical-y-reorder-intent.md: [fix] for @lexical/y, with a Verify: paragraph for the behavior change.

Test plan

New: packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts (132 tests). It covers the S7-2 matrix with its controls, blocks of every shape moved up and down, the result of a recorded move (what the writer deletes), each move API, nested lists, tables and table rows, carets, the fallback with no record, and the remaining limits as test.fails, each with a convergence test. Two tests in BlocksAndLines.test.ts are updated as described above.

./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts packages/lexical-y/src/__tests__/unit/BlocksAndLines.test.ts

Before

On main (0cd7448):

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts > S7-2: a block moved up past the paragraph before it > S7-2 a two-item list moved up: a caret in the paragraph it passed stays there (ids 2,1)
AssertionError: expected 'list[bullet](listitem("Xitem one") li…' to be 'list[bullet](listitem("item one") lis…' // Object.is equality

Expected: "list[bullet](listitem("item one") listitem("item two")) / paragraph("AnotherX line")"
Received: "list[bullet](listitem("Xitem one") listitem("item two")) / paragraph("Another line")"

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts > recorded moves of tables and table rows > a table moved up past a paragraph ‖ typing in the paragraph stays there (ids 1,2)
AssertionError: expected 'Another line' not to contain 'Another'

 Test Files  2 failed (2)
      Tests  72 failed | 108 passed | 8 expected fail (188)

Failures by group: S7-2 matrix 8, every shape 12 (long list, 1x2 and 2x2 tables, up and down), what a recorded move rewrites 10, each move API 8, nested lists 6, tables and rows 10, BlocksAndLines moves 8. The Moves limit group also counts 10, because those test.fails tests pass on main, where the passed block was rewritten instead.

After

 ✓ |unit| packages/lexical-y/src/__tests__/unit/BlocksAndLines.test.ts (56 tests) 2085ms
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts (132 tests) 4828ms

 Test Files  2 passed (2)
      Tests  166 passed | 22 expected fail (188)

Also run with the change:

  • @lexical/y unit tests: 149 files passed and 2 skipped; 5,027 tests passed, 38 expected fail, 2 skipped (on main: 148 files; 4,913 passed, 16 expected fail).
  • Golden corpus: the fuzz's five lanes (no moves, block ids, gc, moves, block ids with moves), 250 seeds each, compared with main for every peer's Yjs state bytes, editor JSON, warnings, fuzz findings, node keys and selection. The three lanes without moves are identical on all 750 seeds. In each lane with moves, 14 of 250 seeds write different bytes (the moved block is rewritten instead of the block it passed), with no other difference. Two of them add a loss(container) finding: text typed inside a table while that table moved, which is the Moves limit.
  • The soak repros: the original S7-2 matrix fails 8 of 36 on main and passes 36 of 36; the shapes matrix fails 8 of 20 on main and passes 20 of 20.
  • pnpm run ci-check passes.
  • Not run: browser-mode tests and the playground e2e specs. No collab Y e2e spec moves a block.
  • Second commit: regressions/Joins.test.ts gives 6 passed | 2 expected fail (8) on main and on this branch; the soak repro it replaces fails 2 of 4 on main (the quote wins). With both commits, @lexical/y unit tests: 150 files passed and 2 skipped; 5,033 tests passed, 40 expected fail, 2 skipped.
  • With all three commits: @lexical/y unit tests: 150 files passed and 2 skipped; 5,037 tests passed, 40 expected fail, 2 skipped. pnpm run ci-check passes.

@vercel

vercel Bot commented Oct 28, 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 29, 2026 6:51pm UTC
lexical-playground Ready Ready Preview Oct 29, 2026 6:51pm 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 28, 2026
@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 5e40824

@zurfyx
zurfyx force-pushed the zurfyx/y-reorder-intent branch from 9d6f599 to a1675b3 Compare October 29, 2026 05:32
@zurfyx
zurfyx changed the base branch from main to zurfyx/y-initial-state-binding October 29, 2026 05:33
@zurfyx
zurfyx force-pushed the zurfyx/y-initial-state-binding branch from cda2d59 to 70830bd Compare October 29, 2026 10:47
@zurfyx
zurfyx force-pushed the zurfyx/y-reorder-intent branch from a1675b3 to 43df673 Compare October 29, 2026 10:47
@zurfyx
zurfyx force-pushed the zurfyx/y-initial-state-binding branch from 70830bd to de27a67 Compare October 29, 2026 18:30
… of the block Lexical moved

## Description

Yjs has no moves, so when a commit reorders blocks, `@lexical/y` keeps some blocks in place and deletes and re-inserts the others. Whatever a collaborator types in a re-inserted block at the same time is lost or stays at its old position. The writer chose the blocks to keep by block count and then by content size, and used Lexical's record of explicit moves (`EditorState#_movedNodes`) only to break exact ties. So a reorder was written as a move of whichever side held less content, not of the block the user moved:

- Peer A moves a two-item list above the paragraph `Another line` (`paragraph.insertBefore(list)`, as a block drag does) while peer B types `X` after `Another`. Every client and a fresh reader end with `list("Xitem one", "item two") / paragraph("Another line")`: B's text lands in a block nobody moved.
- Without concurrency, B's caret after `Another` jumps to the start of `item one` when A's move arrives.
- A table (1x2 or 2x2) or a list with more text than the paragraph, moved up or down past it, leaves B's `X` in a paragraph of its own at the paragraph's old place.

A short list, a quote or a paragraph moved the same way only passed because each held less content than the paragraph.

With this change, the block Lexical recorded as moved is the one rewritten, which is what `@lexical/yjs` already does (`getStableChildKeys`). `Writer#$writeStructure` splits the matched units into those the commit moved and the rest. A unit counts as moved when its node, or an element between it and the container being written (the list of a list item, the item that holds a nested list), is in the record. The rest keep their relative order, since only a move changes it, so all of them stay in place. A moved unit stays too where it is still in order between them (put back where it was, or indented). Block count and size now only decide where Lexical recorded nothing (an editor state set whole) and among moved units. Core already records every move made with `insertBefore`, `insertAfter`, `append` or `splice`, so it is unchanged. The stored layout does not change.

| With a concurrent move of a two-item list | Before | After |
| --- | --- | --- |
| B types in the paragraph the list passes | lands in `item one` | stays in the paragraph |
| B's caret in that paragraph | jumps into `item one` | stays |
| B types in a paragraph a moved table passes | lands in a paragraph of its own | stays |
| B types in the moved list itself | stays in the list | stays at the list's old place |

The last row is the documented Moves limit, which before applied only when the moved block was the smaller one. It covers text typed in the moved block at the same time, a collaborator's caret in it, and what a collaborator typed in it before the mover undoes the move. Two `BlocksAndLines` tests asserted the old trade-off (typing inside a dragged collapsible, and inside a paragraph moved down one slot, survived because the passed block was rewritten). They are now `test.fails`, next to new tests for typing in the block passed. The y.md Moves limit now says which block counts as moved, the fallback, and the undo case.

These are left as expected failures in `regressions/Moves.test.ts`:
- the moved block itself (above);
- a node put back where it was in the same update as a move (an insert next to the sibling it already has, a remove and a re-insert, a move away and back). Lexical's record counts such a node as moved too, so when a real move passes it, the smaller of the two is rewritten. Refining that record would be a core change shared with `@lexical/yjs`.

## Changeset

`.changeset/lexical-y-reorder-intent.md`: `[fix]` for `@lexical/y`, with a `Verify:` paragraph for the behavior change.

## Test plan

New: `packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts` (132 tests). It covers the S7-2 matrix with its controls, blocks of every shape moved up and down, the result of a recorded move (what the writer deletes), each move API, nested lists, tables and table rows, carets, the fallback with no record, and the remaining limits as `test.fails`, each with a convergence test. Two tests in `BlocksAndLines.test.ts` are updated as described above.

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts packages/lexical-y/src/__tests__/unit/BlocksAndLines.test.ts`

### Before

On `main` (0cd7448):

```
 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts > S7-2: a block moved up past the paragraph before it > S7-2 a two-item list moved up: a caret in the paragraph it passed stays there (ids 2,1)
AssertionError: expected 'list[bullet](listitem("Xitem one") li…' to be 'list[bullet](listitem("item one") lis…' // Object.is equality

Expected: "list[bullet](listitem("item one") listitem("item two")) / paragraph("AnotherX line")"
Received: "list[bullet](listitem("Xitem one") listitem("item two")) / paragraph("Another line")"

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts > recorded moves of tables and table rows > a table moved up past a paragraph ‖ typing in the paragraph stays there (ids 1,2)
AssertionError: expected 'Another line' not to contain 'Another'

 Test Files  2 failed (2)
      Tests  72 failed | 108 passed | 8 expected fail (188)
```

Failures by group: S7-2 matrix 8, every shape 12 (long list, 1x2 and 2x2 tables, up and down), what a recorded move rewrites 10, each move API 8, nested lists 6, tables and rows 10, `BlocksAndLines` moves 8. The Moves limit group also counts 10, because those `test.fails` tests pass on `main`, where the passed block was rewritten instead.

### After

```
 ✓ |unit| packages/lexical-y/src/__tests__/unit/BlocksAndLines.test.ts (56 tests) 2085ms
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts (132 tests) 4828ms

 Test Files  2 passed (2)
      Tests  166 passed | 22 expected fail (188)
```

Also run with the change:
- `@lexical/y` unit tests: 149 files passed and 2 skipped; 5,027 tests passed, 38 expected fail, 2 skipped (on `main`: 148 files; 4,913 passed, 16 expected fail).
- Golden corpus: the fuzz's five lanes (no moves, block ids, gc, moves, block ids with moves), 250 seeds each, compared with `main` for every peer's Yjs state bytes, editor JSON, warnings, fuzz findings, node keys and selection. The three lanes without moves are identical on all 750 seeds. In each lane with moves, 14 of 250 seeds write different bytes (the moved block is rewritten instead of the block it passed), with no other difference. Two of them add a `loss(container)` finding: text typed inside a table while that table moved, which is the Moves limit.
- The soak repros: the original S7-2 matrix fails 8 of 36 on `main` and passes 36 of 36; the shapes matrix fails 8 of 20 on `main` and passes 20 of 20.
- `pnpm run ci-check` passes.
- Not run: browser-mode tests and the playground e2e specs. No collab Y e2e spec moves a block.
…ent joins into one line as a known race

## Description

Take the blocks `[heading, quote, paragraph]`. Peer A deletes from the end of the heading to the start of the paragraph, across the quote. At the same time, peer B joins the paragraph into the quote (Backspace at its start). With one client id order, every client and a fresh reader end with the heading's and the paragraph's text in a quote, although neither edit retyped the first line. The same happens to a code block.

A line is stored as its text followed by a marker that holds its type. So a join deletes the earlier line's marker and writes the earlier line's type onto the later line's marker, which now ends the joined line. Here both joins end at the paragraph's marker: A writes the heading's type there and B writes the quote's, and Yjs keeps one of the two writes by client id. The type is one value on that marker, so the two writes cannot both be kept. Keeping the earlier line's type would need each join to record which line it came from, and a follow-up to elect one. This commit records the race as a known limit and does not change what is stored:

- `regressions/Joins.test.ts`: S7-22 for a heading and a code block, with a convergence test for both client id orders and a `test.fails` for the order that loses the first line's type.
- y.md, Limits, Other races: one sentence for two joins at once into the same line.

It is independent of the reorder fix in the previous commit, since a join reorders nothing. The new tests give the same results with and without that commit.

## Changeset

None: a test and website docs only.

## Test plan

### Before

The soak repro on `main` (0cd7448), `./node_modules/.bin/vitest --run --project unit L2-ConcurrentJoinIntoCode.test.ts`:

```
-   "final": "heading",
-   "merged": "heading",
+   "final": "quote",
+   "merged": "quote",
    "mergedShape": "quote(t:\"const t44z = 1; // t46z note t47z\"[\"c1\"] t:\"t49z t50z t51z\")",
      Tests  2 failed | 2 passed (4)
```

### After

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Joins.test.ts`, the same on `main` and on this branch (the behavior is unchanged):

```
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/Joins.test.ts (8 tests) 388ms
      Tests  6 passed | 2 expected fail (8)
```

- `@lexical/y` unit tests with both commits: 150 files passed and 2 skipped; 5,033 tests passed, 40 expected fail, 2 skipped.
- `pnpm run ci-check` passes.
…age as it is stored now

## Description

With the first commit of this PR, the block an update moved is the one written anew. When the same update first changed the content of a container inside that block (a list given an item, then moved into a box that moves too), the writer writes the box's container in place, and then writes the box anew, with its content, for its move. The document is right and every client converges, but the development self-check (`selfCheck: true`) compared the box's old container, which the move deleted, with the editor, and warned `self-check: container <key>: editor [...] != shared []`. On `main` the smaller list was written anew instead of the box, so this did not happen.

The self-check now compares only containers that are still their node's (`state.csByKey`). The box's new container is compared through the write's structural keys, as before.

## Changeset

None: this changes only the opt-in development self-check, and `.changeset/lexical-y-reorder-intent.md` already covers this PR.

## Test plan

New in `regressions/Moves.test.ts`: `a block written, then moved, in one update`, 4 tests (an empty box and a box holding a line, in both client id orders). They use `expectConverged`, which requires no self-check warning.

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/Moves.test.ts`

### Before

On the first two commits of this PR:

```
     × a list given an item, moved into an empty box that moves too (ids 1,2)
     × a list given an item, moved into a box that moves too (ids 1,2)
     × a list given an item, moved into an empty box that moves too (ids 2,1)
     × a list given an item, moved into a box that moves too (ids 2,1)
      Tests  4 failed | 132 skipped (136)
```

Each fails on `expected [ Array(1) ] to deeply equal []`, the warning `self-check: container 13: editor [{"children":[{"children":[{"detail":0,...,"text":"z0",...}]...}]...] != shared []`. On `main` the same edits give no warning.

### After

```
 Test Files  1 passed (1)
      Tests  118 passed | 18 expected fail (136)
```

- `@lexical/y` unit tests: 150 files passed and 2 skipped; 5,037 tests passed, 40 expected fail, 2 skipped.
- `pnpm run ci-check` passes.
@zurfyx
zurfyx force-pushed the zurfyx/y-reorder-intent branch from 43df673 to 5e40824 Compare October 29, 2026 18:49
@zurfyx
zurfyx changed the base branch from zurfyx/y-initial-state-binding to main October 29, 2026 18:49
@zurfyx
zurfyx added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit ccbbf4d Oct 29, 2026
61 of 70 checks passed
zurfyx added a commit that referenced this pull request Oct 29, 2026
… does not know in a block it moves

## Description

An older client of an app (one whose class lacks a field that a newer version of the class added) keeps that field when it edits a node (#9504). A block it moves was different: Yjs has no moves, so the writer deletes the moved block and writes it anew from what the client shows (#9502 chooses which block). The new copy started empty, so the moved block lost every field the client does not own, for every client and for a fresh reader, with no concurrency involved:

- a line's fields on its marker (a note's `tone`), and the fields of its list and outer lists on the marker of every item;
- a block node's or decorator's fields on its Y.Node, and those of everything it holds (lines, lists, inline nodes, slots), which are written anew too;
- the same when the client splits such a line.

With this change, a unit written anew takes what its node stored before the write:

- **Lines.** A marker the write makes for a line that had one takes the fields the old marker stored that the client does not own (`$markerBefore`, read with `attrsBefore`: the attributes as they were before the transaction, which Yjs still holds for a marker the transaction deleted). A line that holds lines (stored as parts) reads each part's old marker, or the whole line's for a new part. Where a diff of a container gives a moved line the marker of another line there, the marker takes the moved line's fields and drops the other line's.
- **Groups.** A list item new to its list, or written anew, takes the list's fields (`g:`) from the item next to it that kept its marker and was in the list before (#9504's rule, which now also requires the second part), so an item that joins another list takes that list's fields. Where no such item exists, the list moved with the item or lost all its items: the item takes the fields the list stored before, from its own old marker if it was in the list, else from another item's, else from an item the list had then (`$priorGroupItem`). So a list moved with an item added, swapped list items, and two lists an older client shows as one all keep their own fields. An outer level takes the fields of its item (`@`) from where that item's were stored, so a nested list moved into a new item takes none of the old item's.
- **Nodes.** A node given new storage takes the fields its old Y.Node stored that the client does not own (`nodeBefore` in `$fillNode`).

What each lookup finds is kept for the whole write, so a node written twice in one write (moved into a container that is itself written anew) still takes what it had before the first. Only names the client does not own are copied; it writes and deletes its own as before.

Ownership now also counts a flat NodeState with no schema (a plain `parse` function) where the client writes it as a field: on a node stored as its own Y.Node, and on text (`jsonFieldsOf`). With #9504, such a state reset to its default was never deleted there, so every other client and a fresh reader kept the old value. On `main` it was deleted.

Clients of one version store what they stored before, with one exception, in names that no class of theirs declares on a line (a list item's `checked` left on a paragraph): those now stay with the line when a diff gives it another line's marker, where before they stayed with the marker.

Remaining limits, as `test.fails` and in y.md:

- The text of a moved block is still written from what the client shows, so it loses the text formats, text-class fields and link fields the client does not know. Keeping them needs the old characters' formats carried into the new runs, character by character.
- A nested list whose item moves into another list does not take that list's fields: the outer level would need them from the items of the list it joins.

Not changed here: an item that keeps its marker while its list changes (an indent or outdent of a list's first item) keeps the list fields of its old place, as with #9504.

## Changeset

`.changeset/lexical-y-moved-blocks-keep-fields.md`: `[fix]` for `@lexical/y`, with a `Verify:` paragraph.

## Test plan

`regressions/SchemaSkew.test.ts`: #9504's `test.fails('limit: a block an older client moves keeps the fields it does not know')` is now a passing test. A new F2 group compares each move made by an older client with the same move made by a newer one: lines, lists, block nodes, decorators, nested content, nested lists and inline nodes, moved down, up and into a new container; splits; slots; items moved within and between lists; swapped list items; lists shown as one; lists moved with new items; nested lists moved into new or other items; moves into containers that move too; a line that gets another line's marker; a line holding lines. Another group checks a flat NodeState with no schema, reset in place and on a move.

`./node_modules/.bin/vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts`

### Before

On the merge of #9502 and #9504:

```
 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > F2: an older client that moves a block keeps the fields it does not know > a block node moved down past a paragraph
AssertionError: expected { root: { children: [ …(2) ], …(5) } } to deeply equal { root: { children: [ …(2) ], …(5) } }
-         "tone": "warm",
+         "tone": "",

 FAIL  |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts > S7-3, F2: a flat NodeState with no schema > on a block node, reset, stays reset
+         "test_skew_memo": "old",

 Test Files  1 failed (1)
      Tests  41 failed | 66 passed | 4 expected fail (111)
```

### After

```
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts (111 tests)
 Test Files  1 passed (1)
      Tests  107 passed | 4 expected fail (111)
```

- `@lexical/y` unit tests: 151 files passed and 2 skipped; 5,144 tests passed, 44 expected fail, 2 skipped (on the merge: 151 files passed and 2 skipped; 5,094 tests passed, 42 expected fail, 2 skipped).
- Golden corpus: the fuzz's five lanes (no moves, block ids, gc, moves, block ids with moves), 250 seeds each, compared with the merge for every peer's Yjs state bytes, editor JSON, warnings, fuzz findings, node keys and selection. Editor JSON, warnings, findings, keys and selection are identical on all 1,250 seeds. Bytes differ on 3 seeds in each lane without moves and 2 in each lane with moves, all from the exception above.
- `pnpm run ci-check` passes.

This branch was successfully deployed

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

3 participants