Repository navigation
Conversation
zurfyx
requested review from
acywatson,
etrepum,
ivailop7 and
potatowagon
as code owners
October 30, 2026 00:41
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
…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.
zurfyx
force-pushed
the
zurfyx/y-undo-run-ends
branch
from
October 30, 2026 01:32
1c99d6d to
a09ceb8
Compare
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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'sdelete 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 contentthe 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 andundone again and again at one place leaves a bounded number of items.
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 contentbefore 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 itsown 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
restateRunEndswrites is recorded on its step before it iswritten, and
undo()/redo()asked for during the operation (from alistener) does nothing.
regressions/Formats.test.tsL1B-1 expected C's text, typed over J'splain 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 sixclient 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,
boundsOwnInserton an insert's and a format change's run ends, andlisteners that undo again, clear the history or throw (of the undo manager
and of the follow-up write).
Before
After
The
@lexical/yand@lexical/yjsunit suites pass, the randomized onesincluded (
Fuzz,FollowUpFuzz,ConcurrentShapesFuzz,NestedFuzz,SlotsFuzz):pnpm run ci-checkpasses.