Skip to content

[lexical-y] Bug Fix: A run ends in its own container, and no item is linked next to an item of another container - #9693

Merged
zurfyx merged 2 commits into
mainfrom
zurfyx/y-run-end-own-container
Nov 1, 2026
Merged

zurfyx merged 2 commits into
mainfrom
zurfyx/y-run-end-own-container

Conversation

@zurfyx

@zurfyx zurfyx commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Description

A format write that restates another client's run at the end of its range
gives that run an end of its own, after the end that client's change gave it
(#9679, rule 3). That end came from changeEnd, which walks the client's
items in clock order and could return an item in another container (a write
that colored text in a table cell and then in the root). The writer then
linked its new item after that item, into the root's list, while naming the
cell as its parent. Every other client integrated the item into the cell, but
the writer's own document held a malformed item: later changes next to it
made the writer's editor stop reading part of the document, for good and
without a warning (a downstream app's long notes: 15 of 133 sessions on
main, 0 of 132 before #9679).

Now:

  • changeEnd and lastOwnItemBefore only look at items in the run's own
    shared type: a run cannot end in another container.
  • The cursor refuses to link a new item next to an item of another type
    (checkLinkable in newItem and insertAfter, in every build): the write
    throws, and the existing recovery rewrites the document from the editor
    and reports @lexical/y/recovered-write, instead of storing a malformed
    item. closeRestoredRuns skips such a pair.
  • Tests check the structure: misLinkedItems (no item with a neighbour in
    another type) runs in the harness's expectConverged, after every op of
    UndoSpreadFuzz, and in a new structure fuzz.

Recorded as D96. Closes #9694.

Test plan

Before

$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/StructureFuzz.test.ts packages/lexical-y/src/__tests__/unit/regressions/CrossContainerEnd.test.ts
      Tests  11 failed | 191 passed (202)
AssertionError: expected [ '6414644327309859:22', '1:5', '2:2' ] to deeply equal []
$ LEXICAL_Y_GC=1 (same files)
      Tests  11 failed | 191 passed (202)

After

$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/StructureFuzz.test.ts packages/lexical-y/src/__tests__/unit/regressions/CrossContainerEnd.test.ts
      Tests  202 passed (202)
$ LEXICAL_Y_GC=1 (same files)
      Tests  202 passed (202)
$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y
 Test Files  240 passed | 2 skipped (242)
      Tests  19229 passed | 304 expected fail | 130 skipped (19663)
$ LEXICAL_Y_GC=1 vitest --run --project unit packages/lexical-y
      Tests  19226 passed | 307 expected fail | 130 skipped (19663)
$ pnpm run ci-check
exit 0

With gc on, DisposeMemory.test.ts exceeded its heap bound once with 40
workers in parallel; it passes alone (three runs). The 1,000-seed version
of the structure fuzz: 48 (gc off) and 49 (gc on) seeds failing before,
0 after.

…linked next to an item of another container

## Description

A format write that restates another client's run at the end of its range
gives that run an end of its own, after the end that client's change gave it
(#9679, rule 3). That end came from `changeEnd`, which walks the client's
items in clock order and could return an item in another container (a write
that colored text in a table cell and then in the root). The writer then
linked its new item after that item, into the root's list, while naming the
cell as its parent. Every other client integrated the item into the cell, but
the writer's own document held a malformed item: later changes next to it
made the writer's editor stop reading part of the document, for good and
without a warning (a downstream app's long notes: 15 of 133 sessions on
`main`, 0 of 132 before #9679).

Now:

- `changeEnd` and `lastOwnItemBefore` only look at items in the run's own
  shared type: a run cannot end in another container.
- The cursor refuses to link a new item next to an item of another type
  (`checkLinkable` in `newItem` and `insertAfter`, in every build): the write
  throws, and the existing recovery rewrites the document from the editor
  and reports `@lexical/y/recovered-write`, instead of storing a malformed
  item. `closeRestoredRuns` skips such a pair.
- Tests check the structure: `misLinkedItems` (no item with a neighbour in
  another type) runs in the harness's `expectConverged`, after every op of
  `UndoSpreadFuzz`, and in a new structure fuzz.

Recorded as D96 (its issue link is a placeholder until the issue is filed).

## Test plan

### Before

```
$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/StructureFuzz.test.ts packages/lexical-y/src/__tests__/unit/regressions/CrossContainerEnd.test.ts
      Tests  11 failed | 191 passed (202)
AssertionError: expected [ '6414644327309859:22', '1:5', '2:2' ] to deeply equal []
$ LEXICAL_Y_GC=1 (same files)
      Tests  11 failed | 191 passed (202)
```

### After

```
$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/StructureFuzz.test.ts packages/lexical-y/src/__tests__/unit/regressions/CrossContainerEnd.test.ts
      Tests  202 passed (202)
$ LEXICAL_Y_GC=1 (same files)
      Tests  202 passed (202)
$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y
 Test Files  240 passed | 2 skipped (242)
      Tests  19229 passed | 304 expected fail | 130 skipped (19663)
$ LEXICAL_Y_GC=1 vitest --run --project unit packages/lexical-y
      Tests  19226 passed | 307 expected fail | 130 skipped (19663)
$ pnpm run ci-check
exit 0
```

With gc on, `DisposeMemory.test.ts` exceeded its heap bound once with 40
workers in parallel; it passes alone (three runs). The 1,000-seed version
of the structure fuzz: 48 (gc off) and 49 (gc on) seeds failing before,
0 after.
@vercel

vercel Bot commented Nov 1, 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 Nov 1, 2026 4:16am UTC
lexical-playground Ready Ready Preview Nov 1, 2026 4:16am UTC

Request Review

@meta-code-review

meta-code-review Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 1, 2026, 4:32 AM UTC

@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 Nov 1, 2026
@pkg-pr-new

pkg-pr-new Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 294b34e

## Description

D96 links its issue, #9694 (it named a placeholder). Documentation only.

## Test plan

### Before

D96 linked a placeholder issue.

### After

```
$ prettier --check packages/lexical-y/DECISIONS.md
All matched files use Prettier code style!
```

@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. A format run in one Yjs shared type can never be ended by an item in another, so keeping changeEnd and lastOwnItemBefore inside the run's own type (packages/lexical-y/src/internal/Cursor.ts:189, :215) is the right rule. checkLinkable then turns any future cross-container link into a recovered write instead of a malformed item. CrossContainerEnd and StructureFuzz fail without the Cursor.ts changes and pass with them, with garbage collection on and off. The changeset follows CONTRIBUTING.md, including the Verify: paragraph a behaviour fix needs.

Two optional, non-blocking suggestions:

  1. Correct D96's account of the other clients (packages/lexical-y/DECISIONS.md:61, also Cursor.ts:150-151 and CrossContainerEnd.test.ts:17-18). These say every other client integrated the stray end item into the table cell. In @y/y, an item that has an origin is encoded without its parent, and a receiving client takes the parent from the origin item (Item#write, and struct.parent = struct.left.parent in utils/encoding.js). Peers therefore put it in the root's list, right after the other author's own null, where it changes nothing; only the writer's own copy names the cell. src/__tests__/utils/structure.ts:15-16 and #9694 already describe it that way ("integrates it from its origin"). packages/lexical-y/AGENTS.md makes DECISIONS.md the record later changes are checked against, so the row's reasoning is worth fixing.

  2. Let StructureFuzz name a failing seed (StructureFuzz.test.ts:195). With checkLinkable in place, a regression of the changeEnd filter no longer stores a malformed item, so misLinkedItems stays empty. The recovered-write warning goes to Lexical's default onWarn, which throws it in a microtask, so the run reports all 200 tests passed plus unhandled errors and names no seed. Recording warnings in the fuzz's editors and expecting no @lexical/y/recovered-write after each step would point such a failure at its seed. Harness tests have the same gap: expectConverged only rejects self-check warnings (harness.ts:480).

| D24 | **Yjs's format cleanup is off** in `@lexical/y` containers and on every relay that loads a state (`createYDoc`). A delete keeps the ends of each deleted formatted run (within a bound per format key until D86 removed the bound: a delete keeps every format item it leaves). Not reported upstream. | The cleanup deletes format items a concurrent writer may still rely on. Upstream already knows that skipping it is the app's trade-off. | `DECIDED` | 10-28 | [#9400], [y.md: Servers][y-servers] |
| D86 | **A delete keeps every format item it leaves (no collapse).** The collapse of the run of deleted content and format items a delete leaves (`Cursor#collapseGap`, D24's bound per format key) is removed; D24's cleanup-off stays. | The collapse dropped ends that a concurrent writer's restore or an undo relied on, and the value ran on: #9575, #9609, a #9620 cell, the concurrent restore cells of #9640, a KeepEndsSessions seed. Every attempt to keep the right ends in the collapse (#9617) needed to know which write relies on which item. Measured against main, same seeds: a three-client view fuzz of 15,000 seeds: 0 seeds wrong where main is right, 631 where main spreads a value and this does not. Cost: each formatted run whose text is deleted keeps its two items, none per keystroke or plain delete (F24A-1: 40, 80, 120 after 20, 40, 60 words typed and deleted at one place); a comment added and its word deleted 3,000 times at one place: 6,002 live format items (main 6); a downstream document library's corpus of 189 documents x1.003 at 10 cycles, x1.26 at 150; Jot x1.01 on its default profile, a concentrated recolor 438 items (main 345). Accepted: the linear growth with deleted formatted text, over the bound and its spreads. | `DECIDED` | 10-31 | [#9575], [#9609] |
| D88 | **A client keeps its own format items from garbage collection while they are undoable (memory only).** The binding sets Yjs's `Item.keep` on the format items its own transactions and its undo manager's insert, and on the end of each run they delete; it releases them, between transactions, when both undo stacks are empty (after a clear, an undo that leaves nothing, or a write they did not capture), and when it is disposed or its document destroyed. An item another binding or an `UndoManager` step also keeps stays kept. Releasing collects nothing, as Yjs's `UndoManager` collects nothing when it releases what its steps kept: any `UndoManager` on the document may still restore the item. | An undo reads its change's format items, and the end each gave a run it deleted, after a collaborator deleted them (`isKeptByUndo`, `changeEnd`, `closeRestoredRuns`). With Yjs's default garbage collection, which products use, those were collected, the undo could not tell them apart from other content, and the restored value ran on over the rest of the document ([#9623]; a two-client undo fuzz: 221 of 3,000 seeds to 0). Nothing new is written and other clients are unchanged. The cost is local: a format item a collaborator deletes while this client can undo the change that wrote it keeps its deleted content in this client's document, and in a state it encodes, until the document reloads or a relay that loads its state with gc on compacts it: about 25 bytes per such deletion, in this client only (+0.6% to 0.8% of the client's state in a realistic 4,500-edit session). Accepted over a stored change (a `gcFilter` keeping every format item: +1.9% to 7.6% bytes on long notes, in every peer and relay) and over collecting on release, which was tried and dropped: it destroyed what another `UndoManager` on the same document still needed. A `gcFilter` keeping every format item fixed the same cases, but changes what every peer and relay stores (+1.9% to 7.6% bytes on long notes). | `DECIDED` | 10-31 | [#9623], [y.md: Limits][limits] |
| D96 | **A run ends in its own container, and no item is linked next to an item of another container.** `changeEnd` and `lastOwnItemBefore` look only at items in the run's shared type; the cursor refuses to link a new item next to an item of another type (the write throws and is recovered from, `RECOVERED_WRITE_WARNING`). | The end a write gives a restated run at its range's end (D78's rule 3, [#9679]) came from `changeEnd`, which walks the client's items in clock order across containers and could return an item in another type; the writer linked its end into that type's list while naming the run's container as its parent. Every other client integrated the item into the right list, but the writer's own document held a malformed item, and its editor stopped reading part of the document for good (a downstream app's long notes: 15 of 133 sessions). A structure check (no item's neighbour in another container) now runs in every harness convergence check, the undo fuzz and a structure fuzz. | `DECIDED` | 11-01 | [#9694] |

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: peers don't integrate the stray end into the cell. An item with an origin is encoded without its parent, and @y/y takes the parent from the origin item on receipt, so every other client puts it in the root's list, after the author's own null, where it changes nothing. Only the writer's copy names the cell. utils/structure.ts:15-16 and #9694 already say this; the same sentence is in Cursor.ts:150-151 and CrossContainerEnd.test.ts:17-18.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, you are right: peers take the parent from the origin and put the item in the root, where it changes nothing. Fixed in D96, checkLinkable's comment and the CrossContainerEnd header, in #9696 (096f779) to keep this head stable.

expect(misLinkedItems(doc), `after step ${i}`).toEqual([]);
}
}
expect(errors).toEqual([]);

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: with checkLinkable in place, a regression of the changeEnd filter is refused and recovered, so misLinkedItems stays empty. The recovered-write warning reaches the default onWarn and surfaces only as an unhandled error, with every seed reported passed. Collecting warnings here and expecting no @lexical/y/recovered-write would make the failing seed show up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in #9696 (096f779): the fuzz records the editors' warnings and expects none after each step. With the same-type filter in changeEnd removed it now names 9 of 200 failing seeds (it reported 200 passed plus unhandled errors before). The harness gap is closed there too: expectConverged fails on a recovered write.

@zurfyx
zurfyx added this pull request to the merge queue Nov 1, 2026
zurfyx added a commit that referenced this pull request Nov 1, 2026
…9694 (an editor stopped matching its own document in long sessions)

Reverts #9679 whole, on top of current `main` (keeps #9684 and #9685).

With #9679, a downstream app's long-note sessions end with an editor that
no longer matches its own Yjs document (15 of 133 sessions; 0 of 132 on
the commit before #9679). Reverting only part of it does not work: the
delete rule (D86) alone is worse on a recorded session (2810), and the
divergence was traced to one of its hunks (the end a restated run gets at a range's end, which could be linked into another container; #9693 fixes that at the source). This PR is the fallback if #9693 is not taken. Closes #9694 if merged instead of #9693.

What this brings back, as before #9679:

- A delete collapses the run of deleted content and format items it
  leaves to a bounded number per format key (D24's bound): the cases it
  fixed (#9575, #9609, a #9620 cell, #9640's concurrent restore cells) fail
  again and are pinned with their issues.
- No format items kept from garbage collection: with gc on, an undo can
  again put a value back past its end (#9623). The harness keeps its
  `LEXICAL_Y_GC` switch (added by #9679), and the cells that fail only with
  gc on are pinned as `test.fails` with gc on, naming #9623.
- The restore rules for #9671 and the restore scan for #9599 (D78) are
  gone: those cases are open again.

`DECISIONS.md` keeps D86 and D88 (now `REVERTED`, with the reason) and
reopens D78, rather than rewriting them. `y.md` goes back to the behavior
before #9679. The two changesets of #9679 are removed: it was not part of
a release, so there is nothing to announce.

`UndoSpreadSession2810.test.ts` (added by #9679) is kept: it passes without
D86 and D88 as well.

`main` 60022ae: the downstream app's long-note sessions diverge in
15 of 133.

```
$ LEXICAL_Y_GC=0 vitest --run --project unit
 Test Files  1 failed | 648 passed | 4 skipped (653)
      Tests  1 failed | 28071 passed | 481 expected fail | 135 skipped (28688)
$ LEXICAL_Y_GC=1 vitest --run --project unit
 Test Files  2 failed | 647 passed | 4 skipped (653)
      Tests  2 failed | 28055 passed | 496 expected fail | 135 skipped (28688)
$ pnpm run ci-check
exit 0
```

The failures are load flakes with 40 workers: `DisposeMemory.test.ts` (heap
bound; gc off and on) and `MarkdownEscaping.test.ts` (5 s timeout, gc on).
Each passes alone (two runs each).
Merged via the queue into main with commit 97c9fb8 Nov 1, 2026
65 checks passed
zurfyx added a commit that referenced this pull request Nov 1, 2026
…to the items the store holds

## Description

Stacked on #9693 (it extends that PR's structure check).

An undo that brings back a run gives it an end of this client's own where a
closer ended it (`closeRestoredRuns`). It found where in one transaction and
wrote the null in a second one, keeping the item objects it had found. The
first transaction's scan splits items and its cleanup merges them back, so
the item to the null's left could be a piece no longer in the store. The
null was linked to that piece: the merged item in the store kept its old
right, so a walk of the list skipped the null. Only the undoing client's
document is affected (others integrate the null from its origin), and its
editor can stop matching its document: every pre-existing editor-vs-document
divergence a downstream long-session fuzz found starts with such a link.

Now:

- `closeRestoredRuns` keeps ids, not items, across the two transactions, and
  resolves them in the store when it writes (splitting there as Yjs does);
  it writes nothing where the two are no longer next to each other.
- The running undo's own items (`isKeptByUndo`) are looked up by id in the
  step's insertions instead of by the item objects an earlier transaction
  saw.
- The structure check (`misLinkedItems`, run by `expectConverged`, the undo
  fuzz and the structure fuzz) also requires each neighbour to link back
  and to have the same map key.

Audit of other item references kept across a transaction: the format-closer
marks and the kept format items are format items, which Yjs never splits or
merges; the stored nodes an undo removes are nested types, likewise. Nothing
else outlives its transaction.

## Test plan

### Before

```
$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRunEndLinks.test.ts
AssertionError: after step 7: expected [ '795599280632996:0' ] to deeply equal []
AssertionError: after step 7: expected [ '2159088886642643:0' ] to deeply equal []
      Tests  2 failed (2)
```

### After

```
$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRunEndLinks.test.ts
      Tests  2 passed (2)
$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y
 Test Files  241 passed | 2 skipped (243)
      Tests  19231 passed | 304 expected fail | 130 skipped (19665)
$ LEXICAL_Y_GC=1 vitest --run --project unit packages/lexical-y
 Test Files  241 passed | 2 skipped (243)
      Tests  19228 passed | 307 expected fail | 130 skipped (19665)
$ pnpm run ci-check
exit 0
```
zurfyx added a commit that referenced this pull request Nov 1, 2026
…D96 on what other clients hold

## Description

Review follow-ups to #9693, tests and docs only:

- `StructureFuzz.test.ts` records the editors' warnings and expects none after each step. With `checkLinkable`, a regression of the run-end rule is refused and recovered from instead of stored, so the structure check alone stays empty and the warning only surfaced as an unhandled error; now the failing seeds are named.
- D96, `Cursor.ts` (checkLinkable's comment) and `CrossContainerEnd.test.ts`: other clients take the stray item's parent from its origin and put it in the root's list, where it changes nothing; only the writer's document holds it wrong.

## Test plan

### Before

With the run-end rule's same-type filter removed, the fuzz reported 200 passed plus unhandled errors.

### After

```
$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/StructureFuzz.test.ts
      Tests  200 passed (200)
$ LEXICAL_Y_GC=1 (same file)
      Tests  200 passed (200)
$ (same-type filter in changeEnd removed) LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/StructureFuzz.test.ts
      Tests  9 failed | 191 passed (200)
```

This branch was successfully deployed

2 active deployments
Preview – lexical — 294b34e6 Deployed Nov 1, 2026 by vercel[bot]
Preview – lexical-playground — 294b34e6 Deployed Nov 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

1 participant