Skip to content

[lexical-y] Bug Fix: End a value an undo brings back where it ended, also when the change that wrote its end is undone - #9633

Draft
zurfyx wants to merge 1 commit into
mainfrom
zurfyx/y-undo-restored-run-ends
Draft

zurfyx wants to merge 1 commit into
mainfrom
zurfyx/y-undo-restored-run-ends

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Parked (10/30)

Parked as a draft: the approach (end a run an undo brings back at the end its run had when the item was deleted) cannot tell, without recording provenance, two cases that need opposite answers:

Review also found that the head here (49e675f) can take the value from a later write of the same client where the end's content was collected, and misses the end of a write with more than 64 comment ids (changeEnd's bound). The tests move to main in a test-only pull request (test.fails where main fails). A different design is being prototyped: a removal never deletes another change's opener and writes its own null (and restore) over its range, as Yjs's own format does, so undoing it never brings back an opener whose end is gone.

Description

An undo (or redo) that brings back a format item opening a value (a comment
id, a format, a link, a style, a NodeState value) already ends that run with
a null of its own when the item that ended it belongs to another change
(closeRestoredRuns). Two shapes were missed, and the value then covered
everything after it, on every client and in a fresh read of the document:

The binding now notes, for each format item opening a value that a change
of this client's deletes, the null that ended its run right after that
change (deleted items keep their place, so the end can be found again after
collection). When an undo brings the item back and the change's own end
cannot be found, the run ends right after that noted end. And a live null of
another change that ends the run is now ended before also where no content
shows between the restored item and it yet, when that null is the end its
change gave a run it opened (isChangeEnd): a null by which a change made
text inside the run plain (a hole that change restores the value after) is
no such end, and ending before it would take the value off the text after
the hole.

Every such null is written inside the local undo, never while applying a
remote change. Recorded as DECISIONS.md D83 (proposed; the number may shift at landing): it writes one null
per restored run another change's end closed, also where nothing shows yet.

Closes #9623
Closes #9620

Changeset

.changeset/y-undo-restored-run-ends.md (@lexical/y patch, [fix] with a Verify: paragraph).

Decision for review

DECISIONS.md D83 is proposed here (the number may shift at landing): an undo that brings back a value brings back its end, at the cost of one null per such run, also where no content shows yet. The concurrent case where a collaborator's write relies on a run end while its writer undoes it ([#9599], [#9622]) stays open; the sequential undo of a write whose end a live run relies on is #9624.

With #9617

#9617 (keep-ends) changes how a delete's collapse treats run ends; this PR adds nulls only inside a local undo and deletes nothing, so the two touch different code paths (Cursor#collapseGap there, YBinding#closeRestoredRuns here). Tested together on main + #9617 (6b89fd6) + this change: the same regression tests and fuzz pass (147/147, 3,000/3,000), and against main + #9617 the view property fuzz (5,000 seeds) gives 4,996 identical, 2 last-line spreads of #9617's fixed, and 2 differences where #9617 alone spreads a value onto text that did not have it; run-sequence and comment-run fuzz give the same results as main + #9617.

Test plan

Before

main (4421e5b):

$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunEnds.test.ts
Tests  22 failed | 125 passed (147)
  × comment, removed from the word, its author undoes it first (gc on, ids 1,2,3)
  × a comment its author undid does not cover the rest of the document (ids 1,2)
$ US_SEEDS=3000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/UndoSpreadFuzz.test.ts
Tests  336 failed | 2664 passed (3000)

After

$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunEnds.test.ts
Tests  147 passed (147)
$ US_SEEDS=3000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/UndoSpreadFuzz.test.ts
Tests  3000 passed (3000)

Unit tests of lexical-y, lexical-mark, lexical, lexical-selection,
lexical-list and lexical-table: 22,273 passed, 190 expected fail, 0 failed.
pnpm run ci-check passes.

Against main with the same seeds: a three-client view property fuzz
(5,000 seeds; every client's view after every round, a sentinel line no one
edits): 4,993 identical, 7 differ, and in each of the 7 main has a format
or comment on text this branch leaves without it: 5 are main's value
running onto the untouched last line, and the 2 others, replayed step by
step, are main's value spreading onto text that did not have it before the
undo (this branch shows the text as it was). The run-sequence fuzz (1,000
seeds) fails the same 6 seeds as main (#9599), and the comment-run fuzz
(500 seeds) none on either.

@vercel

vercel Bot commented Oct 31, 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 31, 2026 12:16am UTC
lexical-playground Ready Ready Preview Oct 31, 2026 12:16am 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 31, 2026
@meta-code-review

meta-code-review Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 12:33 AM UTC

@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 49e675f

…also when the change that wrote its end is undone

## Description

An undo (or redo) that brings back a format item opening a value (a comment
id, a format, a link, a style, a NodeState value) already ends that run with
a null of its own when the item that ended it belongs to another change
(`closeRestoredRuns`). Two shapes were missed, and the value then covered
everything after it, on every client and in a fresh read of the document:

- #9623, sequential: B comments a word; A removes the comment (which deletes
  B's opener); B undoes its comment (which deletes B's closer); A undoes its
  removal. The undo brought B's opener back, but B's closer was gone, and
  with garbage collection on (the default) its content was collected, so the
  binding could no longer find it from B's change (`changeEnd`).
- #9620, concurrent: the run's end was still live when the undo ran, but no
  content showed between the restored item and that end yet, so no null was
  written; at the same time the end's own change was undone.

The binding now notes, for each format item opening a value that a change
of this client's deletes, the null that ended its run right after that
change (deleted items keep their place, so the end can be found again after
collection). When an undo brings the item back and the change's own end
cannot be found, the run ends right after that noted end. And a live null of
another change that ends the run is now ended before also where no content
shows between the restored item and it yet, when that null is the end its
change gave a run it opened (`isChangeEnd`): a null by which a change made
text inside the run plain (a hole that change restores the value after) is
no such end, and ending before it would take the value off the text after
the hole.

Every such null is written inside the local undo, never while applying a
remote change. Recorded as DECISIONS.md D83 (proposed; the number may shift at landing): it writes one null
per restored run another change's end closed, also where nothing shows yet.

Closes #9623
Closes #9620

## Test plan

- `regressions/UndoRestoredRunEnds.test.ts` (147 cells): the value is a
  comment id (MarkState), bold, a color style or a NodeState value; a
  collaborator removes it from the whole word, part of it, or (comments)
  the whole document; its author undoes its change first, or not; a third
  client types plain text right after the word, or not; then a redo and
  another undo; garbage collection on and off; two client id orders. Every
  client and a fresh read agree, and the value is on the word only or
  nowhere, never on the typed text or another block. Plus repeated undo and
  redo of the removal (the live format items stay bounded) and #9620's two
  client id orders.
- `UndoSpreadFuzz.test.ts`: two bound editors synced after every op, random
  comment adds, removals, formats, typing, deletes and undos; a comment id
  on two blocks fails (60 seeds by default, `US_SEEDS` for more).
- Mutation checks: without the noted end, 20 cells fail; without the end
  before a live change end where nothing shows yet, #9620's 2 fail.

### Before

main (4421e5b):

```
$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunEnds.test.ts
Tests  22 failed | 125 passed (147)
  × comment, removed from the word, its author undoes it first (gc on, ids 1,2,3)
  × a comment its author undid does not cover the rest of the document (ids 1,2)
$ US_SEEDS=3000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/UndoSpreadFuzz.test.ts
Tests  336 failed | 2664 passed (3000)
```

### After

```
$ vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoRestoredRunEnds.test.ts
Tests  147 passed (147)
$ US_SEEDS=3000 vitest --run --project unit packages/lexical-y/src/__tests__/unit/UndoSpreadFuzz.test.ts
Tests  3000 passed (3000)
```

Unit tests of lexical-y, lexical-mark, lexical, lexical-selection,
lexical-list and lexical-table: 22,273 passed, 190 expected fail, 0 failed.
`pnpm run ci-check` passes.

Against main with the same seeds: a three-client view property fuzz
(5,000 seeds; every client's view after every round, a sentinel line no one
edits): 4,993 identical, 7 differ, and in each of the 7 main has a format
or comment on text this branch leaves without it: 5 are main's value
running onto the untouched last line, and the 2 others, replayed step by
step, are main's value spreading onto text that did not have it before the
undo (this branch shows the text as it was). The run-sequence fuzz (1,000
seeds) fails the same 6 seeds as main (#9599), and the comment-run fuzz
(500 seeds) none on either.

@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. The noted run end and the isChangeEnd check fix both reported shapes (#9623, #9620), and the new regression grid and fuzz fail on main and pass on this branch. A few optional, non-blocking suggestions:

  1. A restored run can still spread when the value's author wrote the same key again before the undo (packages/lexical-y/src/internal/Binding.ts, lines 5339-5342). Steps, with gc on: B bolds two t; A unbolds it; B undoes; B bolds zero in the first paragraph; A undoes. Both clients and a fresh read then show bold from two t to the end of the document; a color style or a NodeState value does the same. The cause: changeEnd is tried first, and once the author's closer has been collected it steps over it and returns the end of the author's next write of that key, so the noted end is never used. main behaves the same, so this is not a regression. But it is the class this PR closes, and the method's JSDoc (lines 5290-5292), D83 and the changeset all describe the noted end as the one used.
    Putting the noted end first is not enough: it breaks the hole case. Take B bolds two t, C unbolds wo, A unbolds the word, C undoes, A undoes: main and this branch correctly bring bold back on all of two t, while noted-end-first leaves wo plain. What passed the PR's tests, both of these cases and the rest of the @lexical/y unit suite for me: keep changeEnd first, but drop its answer when one of the author's structs between the original and that answer has been collected, then fall back to the noted end. If that check imports ContentDeleted, YjsContract.test.ts needs it listed (D14). Per packages/lexical-y/AGENTS.md, the repro would go in src/__tests__/unit/regressions/.
  2. Changeset and D83 wording. If (1) is left for a follow-up, narrow .changeset/y-undo-restored-run-ends.md (line 11) and D83 so they do not promise the case where the author rewrote the key.
  3. D83 status. The row is DECIDED (10-30), while the description calls it proposed. DECISIONS.md's legend defines DECIDED as ratified, so it is worth confirming the status and number at landing.

Comment on lines +5339 to +5342
const gone = opensRun(copy)
? (changeEnd(doc, original) ??
(recorded === undefined ? null : store.getItem(recorded)))
: 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, non-blocking: changeEnd runs first here, and once the author's closer has been collected (gc on) it steps over it and returns the end of the author's next write of the same key. That item is live and elsewhere, so the noted end below is never read. Repro: B bolds two t; A unbolds it; B undoes; B bolds zero; A undoes. Bold then runs from two t to the end of the document (same on main). The JSDoc above (lines 5290-5292) describes the noted end as the one used. Putting recorded first breaks the hole case (C unbolds wo, then undoes it before A's undo), but keeping changeEnd first and dropping its answer when one of the author's structs between original and that answer has been collected passed both cases and the @lexical/y unit suite for me.

This branch was successfully deployed

2 active deployments
Preview – lexical — 49e675f1 Deployed Oct 31, 2026 by vercel[bot]
Preview – lexical-playground — 49e675f1 Deployed Oct 31, 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