Skip to content

[lexical-y] Bug Fix: Keep a comment brought back by undoing its removal off the rest of the document when its author undid the text it was on - #9687

Draft
zurfyx wants to merge 1 commit into
mainfrom
zurfyx/y-restore-end-past-deleted
Draft

zurfyx wants to merge 1 commit into
mainfrom
zurfyx/y-restore-end-past-deleted

Conversation

@zurfyx

@zurfyx zurfyx commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Parked (draft). The fix below guesses which end belongs to which write, and an independent review found a case it gets wrong that main gets right. Kept as a record; the failing cells move to a test-only pull request as test.fails pins.

Why it is parked

closeRestoredRuns takes the end the restored opener's write gave its run from changeEnd: the next item of the key with another value among the author's next 64 structs, in clock order. That scan cannot tell the original write's own end from the end of a later, separate write by the same author: both are the same client's items of the same key, contiguous in clocks, with no content between. main only writes an end there when content is shown between the copy and that item, which happened to hide the guess in the case below; this change also writes it when content follows, and so acts on the guess.

Counterexample (gc off, both client id orders, sequential and concurrent): A types YY before tail and comments YYtail in one step, its run ending at an end the document already held. A then removes the comment from tail in a separate step and undoes that. B removes the comment while A deletes YY; B undoes its removal. main restores the comment on tail; this change writes a null at the separate (undone) write's boundary and tail loses it, on every client and a fresh read (8 of 16 cells; with gc on the unrelated deleted item is collected on B and the scan finds nothing).

The restoring client cannot prove ownership locally: it does not hold the author's undo stack, and nothing stored marks where one write ends. Fixing this needs provenance (which write an end belongs to), not another proximity rule.

The bug it addressed (still open)

A types text and comments (or bolds) it in one undo step; after a sync, B removes the value while A undoes its step; after the next sync, B undoes its removal. B's undo brings A's opener back as a copy; the closer that ended it was A's own and A's concurrent undo deleted it, together with all the text the run was on, so the copy's run covers the rest of the document. 28 of the 64 cells in regressions/UndoRestoredRunOverDeletedText.test.ts fail on main (gc on and off); this change passed all 64, and an independent review's matrices showed 532 improvements against 8 regressions (the case above).


Original description:

Description

A types "yy " and comments it (or bolds it) in one undo step. After a sync,
B removes the comment while A undoes its step, and after the next sync B
undoes the removal: the comment covers the rest of that paragraph and every
paragraph after it, on every client, with garbage collection on or off.

Stored items: A's step writes an opener {c1=true}, the text and a closer
{c1=null}. B's removal deletes A's opener and relies on A's closer. A's
concurrent undo deletes the text and its own closer (it keeps the closer
only when it has seen the removal, so the sequential version was fine). B's
undo brings the opener back as a copy, and closeRestoredRuns finds the end
the opener's own write gave its run (changeEnd), deleted, but wrote an end
there only when content between the copy and that end was shown. Here that
content is the text A's undo deleted, so no end was written and the copy's
run went on to the end of the document.

closeRestoredRuns now writes that end also when content follows it before
the next live item of that key. It is the end the rule already uses; nothing
else changes (the 15,000-seed multi-value view property is identical on
every seed, gc on and off).

Test plan

New regressions/UndoRestoredRunOverDeletedText.test.ts: 8 shapes (a comment
on typed text; two comments and more typing before them; typed and commented
in two undo steps; bold on typed text; text typed at the end of the document;
at the end of a paragraph; the same comment id of the other client live after
it; another comment id live after it), A undoing while B removes and after B
removed, gc off and on, both client id orders (64 cells).

Before

$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunOverDeletedText.test.ts
 × comment on typed text; A undoes while B removes (gc off, ids 1,2)
 × two comments on typed text, then more typing before them; A undoes after B removed (gc off, ids 1,2)
 × comment on typed text, typed and commented in two steps; A undoes while B removes (gc off, ids 1,2)
 ...
-   "two two two",
-   "three three three",
+   "two two two[c1]",
+   "three three three[c1]",
      Tests  28 failed | 36 passed (64)

The same 28 fail with LEXICAL_Y_GC=1.

After

$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunOverDeletedText.test.ts
      Tests  64 passed (64)
$ LEXICAL_Y_GC=1 vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunOverDeletedText.test.ts
      Tests  64 passed (64)
$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y
      Tests  19153 passed | 304 expected fail | 130 skipped
$ LEXICAL_Y_GC=1 vitest --run --project unit packages/lexical-y
      Tests  19152 passed | 307 expected fail | 130 skipped
$ pnpm run ci-check
exit 0

Randomized, seeds not run before, gc off and on, main vs this change: the
multi-value view property (15,000 seeds) identical on every seed; the comment
undo-spread fuzz (3,000 seeds), the run-sequence fuzz (1,000) and the
comment-run fuzz (500) pass on both.

…al off the rest of the document when its author undid the text it was on

## Description

A types "yy " and comments it (or bolds it) in one undo step. After a sync,
B removes the comment while A undoes its step, and after the next sync B
undoes the removal: the comment covers the rest of that paragraph and every
paragraph after it, on every client, with garbage collection on or off.

Stored items: A's step writes an opener `{c1=true}`, the text and a closer
`{c1=null}`. B's removal deletes A's opener and relies on A's closer. A's
concurrent undo deletes the text and its own closer (it keeps the closer
only when it has seen the removal, so the sequential version was fine). B's
undo brings the opener back as a copy, and `closeRestoredRuns` finds the end
the opener's own write gave its run (`changeEnd`), deleted, but wrote an end
there only when content between the copy and that end was shown. Here that
content is the text A's undo deleted, so no end was written and the copy's
run went on to the end of the document.

`closeRestoredRuns` now writes that end also when content follows it before
the next live item of that key. It is the end the rule already uses; nothing
else changes (the 15,000-seed multi-value view property is identical on
every seed, gc on and off).

## Test plan

New `regressions/UndoRestoredRunOverDeletedText.test.ts`: 8 shapes (a comment
on typed text; two comments and more typing before them; typed and commented
in two undo steps; bold on typed text; text typed at the end of the document;
at the end of a paragraph; the same comment id of the other client live after
it; another comment id live after it), A undoing while B removes and after B
removed, gc off and on, both client id orders (64 cells).

### Before

```
$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunOverDeletedText.test.ts
 × comment on typed text; A undoes while B removes (gc off, ids 1,2)
 × two comments on typed text, then more typing before them; A undoes after B removed (gc off, ids 1,2)
 × comment on typed text, typed and commented in two steps; A undoes while B removes (gc off, ids 1,2)
 ...
-   "two two two",
-   "three three three",
+   "two two two[c1]",
+   "three three three[c1]",
      Tests  28 failed | 36 passed (64)
```

The same 28 fail with `LEXICAL_Y_GC=1`.

### After

```
$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunOverDeletedText.test.ts
      Tests  64 passed (64)
$ LEXICAL_Y_GC=1 vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunOverDeletedText.test.ts
      Tests  64 passed (64)
$ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y
      Tests  19153 passed | 304 expected fail | 130 skipped
$ LEXICAL_Y_GC=1 vitest --run --project unit packages/lexical-y
      Tests  19152 passed | 307 expected fail | 130 skipped
$ pnpm run ci-check
exit 0
```

Randomized, seeds not run before, gc off and on, main vs this change: the
multi-value view property (15,000 seeds) identical on every seed; the comment
undo-spread fuzz (3,000 seeds), the run-sequence fuzz (1,000) and the
comment-run fuzz (500) pass on both.
@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 2:31am UTC
lexical-playground Ready Ready Preview Nov 1, 2026 2:31am 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 Nov 1, 2026
@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, 2:54 AM UTC
@pkg-pr-new

pkg-pr-new Bot commented Nov 1, 2026

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: d723f76

@zurfyx
zurfyx marked this pull request as draft November 1, 2026 02:46

@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, thanks! The new condition in closeRestoredRuns is narrow. It fires only when the restored opener's own end is deleted, nothing between the two is shown, and content follows. It ends the run where the existing rule already ends it. The new regression test fails on main and passes here, and it checks both peers and a fresh read of the encoded state.

A few optional, non-blocking suggestions:

  1. Changeset Verify: paragraph (.changeset/y-restored-run-end-past-deleted-text.md). CONTRIBUTING.md (Changesets) and AGENTS.md ask that a fix that changes observable behavior carry a Verify: paragraph; .changeset/lexical-y-undo-run-end.md shows the shape. For example: Verify: type text and comment or bold it in one undo step; undo that step while a collaborator removes the comment or bold; when the collaborator undoes the removal, no client shows it on the rest of the paragraph or the paragraphs after it.
  2. JSDoc placement (packages/lexical-y/src/internal/Binding.ts:535-559). showsPast was inserted between opensRun's doc comment and opensRun itself, so that comment now sits above showsPast's and opensRun has none. Moving showsPast above line 535, or below opensRun, keeps both documented.
  3. A redo cell (packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunOverDeletedText.test.ts). closeRestoredRuns also runs after a redo (Binding.ts:5578-5581). A cell where B redoes the removal and A then redoes its step would cover the new branch on that path too. This is about coverage only.
* Whether live format item `item` changes its key's value: the nearest live
* item of its key before it gives another value (none gives null).
*/
/**

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: showsPast landed between opensRun's doc comment (lines 535-538) and opensRun, so that comment no longer documents anything and opensRun has none. Moving showsPast and its comment above line 535, or below opensRun, keeps both documented.

'@lexical/y': patch
---

[fix] Keep a comment or format that undoing its removal brings back off the rest of the document when its author undid the text it was on at the same time

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: CONTRIBUTING.md (Changesets) asks that a fix that changes observable behavior carry a Verify: paragraph. For example:

Verify: type text and comment or bold it in one undo step; undo that step while a collaborator removes the comment or bold; when the collaborator undoes the removal, no client shows it on the rest of the paragraph or the paragraphs after it.

This branch was successfully deployed

2 active deployments
Preview – lexical — d723f767 Deployed Nov 1, 2026 by vercel[bot]
Preview – lexical-playground — d723f767 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.

1 participant