Skip to content

[lexical-y] Bug Fix: End a restored comment or format run with the writer's own null also where the writer opened the run itself - #9562

Open
zurfyx wants to merge 1 commit into
mainfrom
zurfyx/fix-y-own-run-restore-end
Open

zurfyx wants to merge 1 commit into
mainfrom
zurfyx/fix-y-own-run-restore-end

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 30, 2026

Copy link
Copy Markdown
Member

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.

…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.
@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 12:46am UTC
lexical-playground Ready Ready Preview Oct 30, 2026 12:46am 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@9562

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: c1a7909

This branch was successfully deployed

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