Repository navigation
Conversation
…iter's own null also where the writer opened the run itself
## Description
When a client inserts text without a value inside a run of that value (uncommented text typed inside a comment, plain text inside bold), it restores the value after its text and relies on the item that ends the run. Rule 5 of `Cursor#format` makes a restore whose run another client's change opened write a null of its own right after that change's end, because that end can go while the restore stays: the change's undo deletes it, and a concurrent delete may drop it (a delete keeps a bounded number of the format items it leaves in a run of deleted content).
Two cases were left out, and in both the restored value ran on to the end of the document after a collaborator's concurrent delete dropped the run's end:
- **The writer opened the run itself.** `restatedEnd` skipped runs this client opened. A collaborator's delete that has not seen the restore can still drop that end. For example, a client comments a word and later, offline, types into it while a collaborator deletes text next to the commented word, which was deleted earlier. When the client comes back, the comment covers every later word on every client.
- **The run was opened by a write that also restored it.** For example, a comment written with uncommented text inside it, in one write. The run's end is then the end the restore wrote, but the check compared against the null before that text (the opener's `changeEnd`), so no own end was written.
Now a restore gets its own end after the end of any write in the chain that gave the run its value (the opener, or a restore in it: `runChain`, which replaces `runOpener`), whichever client wrote it. The extra null is written only where a restore is written, right after an end that already exists, so it adds at most one item per such insert, and the bounded collapse can remove it later like any other item (the growth-bound tests pass unchanged).
## Changeset
`.changeset/lexical-y-own-run-restore-end.md`: `@lexical/y` patch.
## Test plan
New `packages/lexical-y/src/__tests__/unit/OwnRunRestoreEnd.test.ts`, run in both client id orders:
- A comments "Kilo", then types uncommented "X" inside it; B deletes the word, then deletes the comment's opener and end with raw Yjs deletes (for B they bracket deleted content only), standing in for a concurrent deletion of that end such as the bounded collapse.
- The comment is written with uncommented "X" inside it, then A types uncommented "Y" inside it.
Each test expects nothing after the word to carry the comment.
`pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/OwnRunRestoreEnd.test.ts`
### Before
```
× ... ends where the comment ended when a collaborator's delete drops the comment's end (ids 1,2)
× ... ends where the comment ended when the comment was written with a hole in it (ids 1,2)
× ... ends where the comment ended when a collaborator's delete drops the comment's end (ids 2,1)
× ... ends where the comment ended when the comment was written with a hole in it (ids 2,1)
AssertionError: expected 'aa X bb[c1] cc[c1]' to be 'aa Xbb cc' // Object.is equality
AssertionError: expected 'aa Y bb[c1] cc[c1]' to be 'aa Ybb cc' // Object.is equality
Tests 4 failed (4)
```
With only the own-client exemption removed, the second test still fails in both orders. With only the chain change, the first does.
### After
```
Tests 4 passed (4)
```
- `pnpm exec vitest run --project unit packages/lexical-y`: 165 files passed (7483 tests, 46 expected fail), no failures.
- `pnpm run ci-check` passes.
- In a randomized long-session soak (2 people and an agent, 2 simulated hours), the four sessions that showed this spread now show none: 12, 8, 8 and 4 unexplained formatted words become 0. Twenty-four other sessions show the same results with and without the change.
- Extended fuzz (`FUZZ_SEED_START=50000 FUZZ_SEEDS=2000`): the same seven seeds fail with and without the change, with the same messages, so none of them comes from it.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
zurfyx
requested review from
acywatson,
etrepum,
ivailop7 and
potatowagon
as code owners
October 30, 2026 00:44
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: |
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
When a client inserts text without a value inside a run of that value (uncommented text typed inside a comment, plain text inside bold), it restores the value after its text and relies on the item that ends the run. Rule 5 of
Cursor#formatmakes a restore whose run another client's change opened write a null of its own right after that change's end, because that end can go while the restore stays: the change's undo deletes it, and a concurrent delete may drop it (a delete keeps a bounded number of the format items it leaves in a run of deleted content).Two cases were left out, and in both the restored value ran on to the end of the document after a collaborator's concurrent delete dropped the run's end:
restatedEndskipped runs this client opened. A collaborator's delete that has not seen the restore can still drop that end. For example, a client comments a word and later, offline, types into it while a collaborator deletes text next to the commented word, which was deleted earlier. When the client comes back, the comment covers every later word on every client.changeEnd), so no own end was written.Now a restore gets its own end after the end of any write in the chain that gave the run its value (the opener, or a restore in it:
runChain, which replacesrunOpener), whichever client wrote it. The extra null is written only where a restore is written, right after an end that already exists, so it adds at most one item per such insert, and the bounded collapse can remove it later like any other item (the growth-bound tests pass unchanged).Changeset
.changeset/lexical-y-own-run-restore-end.md:@lexical/ypatch.Test plan
New
packages/lexical-y/src/__tests__/unit/OwnRunRestoreEnd.test.ts, run in both client id orders:Each test expects nothing after the word to carry the comment.
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/OwnRunRestoreEnd.test.tsBefore
With only the own-client exemption removed, the second test still fails in both orders. With only the chain change, the first does.
After
pnpm exec vitest run --project unit packages/lexical-y: 165 files passed (7483 tests, 46 expected fail), no failures.pnpm run ci-checkpasses.FUZZ_SEED_START=50000 FUZZ_SEEDS=2000): the same seven seeds fail with and without the change, with the same messages, so none of them comes from it.