Skip to content

[lexical-website][lexical-y] Bug Fix: Keep the fields an older client does not know in a block it moves - #9527

Merged
zurfyx merged 3 commits into
mainfrom
zurfyx/y-move-keeps-unknown-fields
Oct 29, 2026
Merged

zurfyx merged 3 commits into
mainfrom
zurfyx/y-move-keeps-unknown-fields

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Note

Stack, part 8 of 10. Based on #9504 (zurfyx/y-preserve-unknown-attrs). 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 zurfyx/y-reorder-intent #9508
7 #9504 zurfyx/y-preserve-unknown-attrs #9502
8 #9527 (this PR) 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

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 ([lexical-website][lexical-y] Bug Fix: An older client keeps the fields and content it cannot read #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.

Restack (October 29)

Rebased from zurfyx/stack-9502-9504 (#9502 with #9504 merged in) onto #9504, which now sits on #9502, so the merge branch drops out; no conflicts.

New commits:

  • N2: attrsBefore moves to Unread.ts and reads the document the write sees: it skips what an unread remote change inserted, as it skips what this transaction inserted, and counts as there before the transaction only what that transaction removed (Unread.test.ts). A block an older client moves while a remote change waits to be read keeps what its editor shows, so the room ends as when the move is written before the change arrives (N2: move cases in SchemaSkew.test.ts). It is the one owner of "the attributes before this write"; $markerBefore, nodeBefore and [lexical-playground][lexical-website][lexical-y] Bug Fix: Hold back remote changes to the line being composed until the composition ends #9507's held-attribute check read through it.
  • N1 (documentation and test): a line an older client splits keeps a field it does not know on both lines, the empty line Enter makes at the end of a line excepted. y.md says so, and N1: tests in SchemaSkew.test.ts pin it.

Important

Decision for Gerard (N1): a split by a client that does not own a field. Kept: this PR's rule, the field stays on both halves. Under #9504's ownership rule that loses nothing: the client may not delete the old marker's field, and it cannot tell whether the newer class copies the field to the new line.

The alternative is one owner per stored marker: take the stored predecessor only where the line's old marker did not survive the write, so the field stays once, on the line that kept the old marker. To switch: in Writer#$writeMarker, take before for a marker the write made only where the line's old end (endsBefore) was deleted by the transaction or no longer ends a live unit; change the N1: expectations from 2 to 1; drop the y.md sentence. #9507's MF-1 test compares composing with not composing, so it holds under either rule.

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.

@vercel

vercel Bot commented Oct 29, 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 7:35pm UTC
lexical-playground Ready Ready Preview Oct 29, 2026 7:35pm 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 29, 2026
@zurfyx
zurfyx force-pushed the zurfyx/y-move-keeps-unknown-fields branch from d475e2d to a4a2302 Compare October 29, 2026 05:53
@zurfyx
zurfyx changed the base branch from zurfyx/stack-9502-9504 to zurfyx/y-preserve-unknown-attrs October 29, 2026 05:53
@zurfyx
zurfyx force-pushed the zurfyx/y-move-keeps-unknown-fields branch from a4a2302 to 89665c7 Compare October 29, 2026 06:15
@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 4054696

@zurfyx
zurfyx force-pushed the zurfyx/y-preserve-unknown-attrs branch from f58c9a3 to 81f87e8 Compare October 29, 2026 19:14
… 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.
…in the document its editor shows

## Description

A block an older client moves keeps the fields it does not know: the write
reads what the block's marker or node stored before this transaction
(`attrsBefore`) and carries the fields it does not own to the block's new
storage. That read walked raw Yjs. When the local update waited for a
remote change the binding had not read yet (see `Unread`), the moved block
took the unread change's values, so the room ended differently from the same
edits written before the change arrived.

`attrsBefore` moves to `Unread.ts`, next to the other reads of the document
the write sees. It skips what an unread change inserted, as it skips what
this transaction inserted, and counts as there before the transaction what
the transaction deleted or only noted as deleted (`deleteItem`, for an item
an unread change deleted too), but not what an earlier write noted. It is
the one owner of "the attributes before this write"; `$markerBefore` and
`nodeBefore` read through it.

## Test plan

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts -t N2
     ✓ an item appended to it, in a list a remote change retones
     ✓ an item added before its first item, in a list a remote change retones
     × a note that a remote change retones, moved down
     × a card that a remote change retones, moved down
     × a list that a remote change retones, moved down
```

With a removal any earlier write noted counted as there:

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Unread.test.ts
   × N2: before a write, an attribute an earlier write removed is gone, one this write removed is not
AssertionError: expected { 'p:future': 'keep' } to deeply equal {}
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts packages/lexical-y/src/__tests__/unit/Unread.test.ts
 Test Files  2 passed (2)
```
… an older client splits keeps a field it does not know on both lines

## Description

A client that splits a line whose marker stores a field none of its classes
declare cannot tell whether the newer class copies that field to the new
line. With this PR's rule, the write keeps the field on both lines: one line
keeps the old marker, and the marker the write makes for the other takes
what the old one stored (`$markerBefore`). The empty line Enter makes at the
end of a line is the exception: it has what the editor shows. Keeping the
field on both lines loses nothing; storing it once would need to know the
newer class's split behaviour.

The rule had no test for a split that keeps the line's type (the F2 split
tests use a note, whose split makes a paragraph and drops the field), and
y.md did not say it. This adds both. A client that composes as it splits
stores the same (see the composition regression tests).

## Test plan

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts -t N1
 ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/SchemaSkew.test.ts (119 tests | 116 skipped) 309ms
 Test Files  1 passed (1)
      Tests  3 passed | 116 skipped (119)
```

Enter at the start, inside and at the end of the line store the field on 2,
2 and 1 lines, for a client that does not declare it in either version.
@zurfyx
zurfyx changed the base branch from zurfyx/y-preserve-unknown-attrs to main October 29, 2026 19:33
@zurfyx
zurfyx force-pushed the zurfyx/y-move-keeps-unknown-fields branch from b2da1ef to 4054696 Compare October 29, 2026 19:33
@zurfyx
zurfyx added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit e6f8eae Oct 29, 2026
53 checks passed

This branch was successfully deployed

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

2 participants