Skip to content

[lexical-y] Bug Fix: Keep the run ends of text an undo removes, and end a value an undo ends past other writes' run ends - #9561

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

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

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Description

Cases where a bold, a comment or another format value spread onto text
nobody gave it, on every client and in a fresh read:

  1. An undo of text typed inside a run of another value deleted the run
    ends that text was written with. F types plain " mid" into bold "one
    two": the write ends the bold before " mid" and gives it back after it.
    E types plain " plain" right after "one": that text lands after F's null
    and writes no item of its own (its value matches). F undoes (whether or
    not it has seen E's text): the undo deleted F's null and F's restore with
    " mid", and " plain" turned bold (or, with a comment, commented). The
    same with a paragraph inserted between the halves of a bold run split by
    Enter, and with bold text typed into plain text (text typed before it
    inside its run lost the bold). A delete of " mid" by hand keeps those run
    ends (Cursor#collapseGap); the undo did not.

    The undo now keeps them (neutralRunEnds, through the undo manager's
    delete filter): the format items of its step next to content of its own
    that it deletes (past format items and content written there since:
    content whose Yjs origins lead to an item of the step), where deleting
    them would not change the value of content that was there before the
    step, or that the undo brings back. Where it keeps a restore (the item
    that gives a run's value back after the text), it also keeps the null
    its write put right after the end of that run, which the restore relies
    on (Cursor#format, rule 5). Items of a format change (next to content
    the step did not write) are deleted as before, so an undo of a format
    change still removes it. Then, as a delete does, the run of items
    without live content the undo leaves is collapsed (collapseUndone,
    through Cursor#collapseDeleted), so text of another value typed and
    undone again and again at one place leaves a bounded number of items.

  2. An insert's run ends inside another write's run restate that run's
    value: F's restore after " mid" gives the rest of A's bold back. A bolds
    "one two"; F types " mid" inside and deletes it (on main), or undoes it
    (with 1.); A undoes the bold: " two" stayed bold. After an undo or redo,
    where an insert's restore (one of the two items one insert wrote around
    text of its own, boundsOwnInsert) gives back the value the content
    before that insert's null had, and the undo or redo changed that value,
    an item of this client's own right after it now gives the content after
    it the new value (restateRunEnds). A format change's closer ends its
    own value and is left alone. The item is recorded on the step the undo
    or redo pushed, and the next redo or undo of that step deletes it before
    restating again (unrestate).

An undo or redo and its follow-up writes are one operation: the steps it
took and pushed are taken from the undo manager's events, by listeners the
binding registers first (so a listener of the app that clears the history
or throws does not skip the follow-up writes, and its error is thrown after
them), what restateRunEnds writes is recorded on its step before it is
written, and undo()/redo() asked for during the operation (from a
listener) does nothing.

regressions/Formats.test.ts L1B-1 expected C's text, typed over J's
plain text after a commented word, to take the comments when J's typing
was undone at the same time: that was case 1 (J's undo deleted the null
C's text relied on). C's text now stays plain.

Changeset

.changeset/lexical-y-undo-run-ends.md ([fix]).

Test plan

New packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts (all six
client id orders where the order matters): bold and comment, a paragraph in
a split bold run, bold text typed into plain text, the undoing client having
seen the collaborator's text, a redo, bounds on the format items left by
typing and undoing at one place (one value, and a new style each time),
controls (no undo, a delete by hand), undo-redo-undo chains of the bold
after a word typed inside it was undone or deleted, a third peer's text
inside the collaborator's, a format change's closer next to undone text,
the null a kept restore relies on surviving a collaborator's delete,
boundsOwnInsert on an insert's and a format change's run ends, and
listeners that undo again, clear the history or throw (of the undo manager
and of the follow-up write).

Before

$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts
 Tests  46 failed | 22 passed (68)
     × bold: text typed after its null stays plain (6 of 6 id orders)
     × comment: text typed after its null stays uncommented (6 of 6)
     × a paragraph inserted into a bold run split by Enter (6 of 6)
     × bold text typed into plain text: text typed before it inside its run stays bold (6 of 6)
     × F has seen E's text when it undoes; then A undoes the bold (6 of 6)
     × text a third peer typed inside the collaborator's text stays plain too (2 of 2)
     × F types " mid" into A's bold and deletes it; A undoes the bold, redoes it, undoes it (6 of 6)
     × a redo puts the text back with its own value
     × the end a kept restore relies on stays ended when a collaborator deletes the run (6 of 6)
     × only an insert's own run ends restate a value (not a format change's)
     ✓ F types " mid" into A's bold and undos it; A undoes the bold, redoes it, undoes it (6 of 6)
     ✓ controls, bounds and stack listener cases
AssertionError: expected 'one plain two*' to be 'one*| plain| two*' // Object.is equality
AssertionError: expected 'one plain two[c1]' to be 'one[c1]| plain| two[c1]' // Object.is equality
AssertionError: expected 'one* /  plain* /  two*' to be 'one* /  plain /  two*' // Object.is equality
AssertionError: expected 'one B two' to be 'one| B*| two' // Object.is equality
AssertionError: expected 'one two*' to be 'one two' // Object.is equality

After

$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts
 Test Files  1 passed (1)
      Tests  68 passed (68)

The @lexical/y and @lexical/yjs unit suites pass, the randomized ones
included (Fuzz, FollowUpFuzz, ConcurrentShapesFuzz, NestedFuzz,
SlotsFuzz):

$ pnpm exec vitest run --project unit packages/lexical-y
 Test Files  166 passed | 2 skipped (168)
      Tests  7551 passed | 46 expected fail | 4 skipped (7601)

pnpm run ci-check passes.

@vercel

vercel Bot commented Oct 30, 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 30, 2026 1:34am UTC
lexical-playground Ready Ready Preview Oct 30, 2026 1:34am 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 30, 2026
@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: a09ceb8

…nd a value an undo ends past other writes' run ends

## Description

Cases where a bold, a comment or another format value spread onto text
nobody gave it, on every client and in a fresh read:

1. An undo of text typed inside a run of another value deleted the run
   ends that text was written with. F types plain " mid" into bold "one
   two": the write ends the bold before " mid" and gives it back after it.
   E types plain " plain" right after "one": that text lands after F's null
   and writes no item of its own (its value matches). F undoes (whether or
   not it has seen E's text): the undo deleted F's null and F's restore with
   " mid", and " plain" turned bold (or, with a comment, commented). The
   same with a paragraph inserted between the halves of a bold run split by
   Enter, and with bold text typed into plain text (text typed before it
   inside its run lost the bold). A delete of " mid" by hand keeps those run
   ends (`Cursor#collapseGap`); the undo did not.

   The undo now keeps them (`neutralRunEnds`, through the undo manager's
   delete filter): the format items of its step next to content of its own
   that it deletes (past format items and content written there since:
   content whose Yjs origins lead to an item of the step), where deleting
   them would not change the value of content that was there before the
   step, or that the undo brings back. Where it keeps a restore (the item
   that gives a run's value back after the text), it also keeps the null
   its write put right after the end of that run, which the restore relies
   on (`Cursor#format`, rule 5). Items of a format change (next to content
   the step did not write) are deleted as before, so an undo of a format
   change still removes it. Then, as a delete does, the run of items
   without live content the undo leaves is collapsed (`collapseUndone`,
   through `Cursor#collapseDeleted`), so text of another value typed and
   undone again and again at one place leaves a bounded number of items.

2. An insert's run ends inside another write's run restate that run's
   value: F's restore after " mid" gives the rest of A's bold back. A bolds
   "one two"; F types " mid" inside and deletes it (on main), or undoes it
   (with 1.); A undoes the bold: " two" stayed bold. After an undo or redo,
   where an insert's restore (one of the two items one insert wrote around
   text of its own, `boundsOwnInsert`) gives back the value the content
   before that insert's null had, and the undo or redo changed that value,
   an item of this client's own right after it now gives the content after
   it the new value (`restateRunEnds`). A format change's closer ends its
   own value and is left alone. The item is recorded on the step the undo
   or redo pushed, and the next redo or undo of that step deletes it before
   restating again (`unrestate`).

An undo or redo and its follow-up writes are one operation: the steps it
took and pushed are taken from the undo manager's events, by listeners the
binding registers first (so a listener of the app that clears the history
or throws does not skip the follow-up writes, and its error is thrown after
them), what `restateRunEnds` writes is recorded on its step before it is
written, and `undo()`/`redo()` asked for during the operation (from a
listener) does nothing.

`regressions/Formats.test.ts` L1B-1 expected C's text, typed over J's
plain text after a commented word, to take the comments when J's typing
was undone at the same time: that was case 1 (J's undo deleted the null
C's text relied on). C's text now stays plain.

## Changeset

`.changeset/lexical-y-undo-run-ends.md` (`[fix]`).

## Test plan

New `packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts` (all six
client id orders where the order matters): bold and comment, a paragraph in
a split bold run, bold text typed into plain text, the undoing client having
seen the collaborator's text, a redo, bounds on the format items left by
typing and undoing at one place (one value, and a new style each time),
controls (no undo, a delete by hand), undo-redo-undo chains of the bold
after a word typed inside it was undone or deleted, a third peer's text
inside the collaborator's, a format change's closer next to undone text,
the null a kept restore relies on surviving a collaborator's delete,
`boundsOwnInsert` on an insert's and a format change's run ends, and
listeners that undo again, clear the history or throw (of the undo manager
and of the follow-up write).

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts
 Tests  46 failed | 22 passed (68)
     × bold: text typed after its null stays plain (6 of 6 id orders)
     × comment: text typed after its null stays uncommented (6 of 6)
     × a paragraph inserted into a bold run split by Enter (6 of 6)
     × bold text typed into plain text: text typed before it inside its run stays bold (6 of 6)
     × F has seen E's text when it undoes; then A undoes the bold (6 of 6)
     × text a third peer typed inside the collaborator's text stays plain too (2 of 2)
     × F types " mid" into A's bold and deletes it; A undoes the bold, redoes it, undoes it (6 of 6)
     × a redo puts the text back with its own value
     × the end a kept restore relies on stays ended when a collaborator deletes the run (6 of 6)
     × only an insert's own run ends restate a value (not a format change's)
     ✓ F types " mid" into A's bold and undos it; A undoes the bold, redoes it, undoes it (6 of 6)
     ✓ controls, bounds and stack listener cases
AssertionError: expected 'one plain two*' to be 'one*| plain| two*' // Object.is equality
AssertionError: expected 'one plain two[c1]' to be 'one[c1]| plain| two[c1]' // Object.is equality
AssertionError: expected 'one* /  plain* /  two*' to be 'one* /  plain /  two*' // Object.is equality
AssertionError: expected 'one B two' to be 'one| B*| two' // Object.is equality
AssertionError: expected 'one two*' to be 'one two' // Object.is equality
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/UndoRunEnds.test.ts
 Test Files  1 passed (1)
      Tests  68 passed (68)
```

The `@lexical/y` and `@lexical/yjs` unit suites pass, the randomized ones
included (`Fuzz`, `FollowUpFuzz`, `ConcurrentShapesFuzz`, `NestedFuzz`,
`SlotsFuzz`):

```
$ pnpm exec vitest run --project unit packages/lexical-y
 Test Files  166 passed | 2 skipped (168)
      Tests  7551 passed | 46 expected fail | 4 skipped (7601)
```

`pnpm run ci-check` passes.

This branch was successfully deployed

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

1 participant