Skip to content

[lexical-y] Bug Fix: Keep a range selection when a collaborator's change splits or shortens its text node - #9535

Open
zurfyx wants to merge 1 commit into
mainfrom
zurfyx/y-range-collapse
Open

zurfyx wants to merge 1 commit into
mainfrom
zurfyx/y-range-collapse

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 29, 2026

Copy link
Copy Markdown
Member

Description

A collaborator's change that shortens, splits or replaces the text node a
range selection is in collapsed the selection to a caret on the other client:
a delete before the range at least as long as the range, a format, comment
or link starting at the range's start, a link on a word before it, or Enter
right after it. It happened in paragraphs, list items, table cells and code
lines, and for a headless writer's edit too.

The read changes that node before the binding puts the selection back, and
Lexical moves a point past the end of a changed node to that end: both
points of the range could land on one offset. The binding then took the
range for a caret the user had collapsed (which it must never widen back
into a range) and left it collapsed.

Whether the selection is a caret is now decided on what the editor showed
when the read began ($showsCaret, captured before the read changes any
node), for every read that puts a selection back: a remote change, a
re-read of containers, and the release of a held composition. A caret the
user collapsed before a remote change arrives still stays a caret.

Closes #9516
Closes #9526

Changeset

.changeset/lexical-y-range-selection-reads.md ([fix]).

Test plan

New packages/lexical-y/src/__tests__/unit/RangeSelectionReads.test.ts:
deletes before the range (forward and backward ranges, both client id
orders), bold, comment and link at the range's start, a link on a word
before it, Enter after it, a bold in a list item, a table cell and a code
line, and a headless writer's comment. Controls (a shorter delete, a format
starting inside the range, Enter before it) pass on both.

Before

$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/RangeSelectionReads.test.ts
 ❯ |unit| packages/lexical-y/src/__tests__/unit/RangeSelectionReads.test.ts (19 tests | 16 failed)
     × "aaa bbb ccc": B selects "ccc", A deletes "bbb"
     × "aaa bbbb ccc": B selects "ccc", A deletes "bbbb"
     × "aaa bbbbbbb ccc": B selects "ccc", A deletes "bbbbbbb"
     × "one two three": B selects "three", A deletes "one two"
     × B selects "t2z t3z", A bolds the range
     × B selects "t2z t3z", A bolds its first word
     × B selects "t2z t3z", A comments the range
     × B selects "t2z t3z", A comments its first word
     × B selects "t2z t3z", A links the range
     × B selects "t2z t3z", A links its first word
     × B selects "t2z t3z", A links a word before it
     × B selects "t2z t3z", A presses Enter right after it
     × in a list item: B selects "t2z", A bolds it
     × in a table cell: B selects "t2z", A bolds it
     × in a code line: B selects "t2z", A bolds it
     × a headless writer's comment on the range keeps it
AssertionError: expected '' to be 'ccc' // Object.is equality

After

$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/RangeSelectionReads.test.ts
 ✓ |unit| packages/lexical-y/src/__tests__/unit/RangeSelectionReads.test.ts (19 tests)
 Test Files  1 passed (1)
      Tests  19 passed (19)

The @lexical/y and @lexical/yjs unit suites pass, the randomized ones
included (Fuzz, FollowUpFuzz, ConcurrentShapesFuzz, NestedFuzz,
SlotsFuzz), also with LEXICAL_Y_BLOCK_ID=1 for the new test and Fuzz:

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

pnpm run ci-check passes.

…nge splits or shortens its text node

## Description

A collaborator's change that shortens, splits or replaces the text node a
range selection is in collapsed the selection to a caret on the other client:
a delete before the range at least as long as the range, a format, comment
or link starting at the range's start, a link on a word before it, or Enter
right after it. It happened in paragraphs, list items, table cells and code
lines, and for a headless writer's edit too.

The read changes that node before the binding puts the selection back, and
Lexical moves a point past the end of a changed node to that end: both
points of the range could land on one offset. The binding then took the
range for a caret the user had collapsed (which it must never widen back
into a range) and left it collapsed.

Whether the selection is a caret is now decided on what the editor showed
when the read began (`$showsCaret`, captured before the read changes any
node), for every read that puts a selection back: a remote change, a
re-read of containers, and the release of a held composition. A caret the
user collapsed before a remote change arrives still stays a caret.

Closes #9516
Closes #9526

## Changeset

`.changeset/lexical-y-range-selection-reads.md` (`[fix]`).

## Test plan

New `packages/lexical-y/src/__tests__/unit/RangeSelectionReads.test.ts`:
deletes before the range (forward and backward ranges, both client id
orders), bold, comment and link at the range's start, a link on a word
before it, Enter after it, a bold in a list item, a table cell and a code
line, and a headless writer's comment. Controls (a shorter delete, a format
starting inside the range, Enter before it) pass on both.

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/RangeSelectionReads.test.ts
 ❯ |unit| packages/lexical-y/src/__tests__/unit/RangeSelectionReads.test.ts (19 tests | 16 failed)
     × "aaa bbb ccc": B selects "ccc", A deletes "bbb"
     × "aaa bbbb ccc": B selects "ccc", A deletes "bbbb"
     × "aaa bbbbbbb ccc": B selects "ccc", A deletes "bbbbbbb"
     × "one two three": B selects "three", A deletes "one two"
     × B selects "t2z t3z", A bolds the range
     × B selects "t2z t3z", A bolds its first word
     × B selects "t2z t3z", A comments the range
     × B selects "t2z t3z", A comments its first word
     × B selects "t2z t3z", A links the range
     × B selects "t2z t3z", A links its first word
     × B selects "t2z t3z", A links a word before it
     × B selects "t2z t3z", A presses Enter right after it
     × in a list item: B selects "t2z", A bolds it
     × in a table cell: B selects "t2z", A bolds it
     × in a code line: B selects "t2z", A bolds it
     × a headless writer's comment on the range keeps it
AssertionError: expected '' to be 'ccc' // Object.is equality
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/RangeSelectionReads.test.ts
 ✓ |unit| packages/lexical-y/src/__tests__/unit/RangeSelectionReads.test.ts (19 tests)
 Test Files  1 passed (1)
      Tests  19 passed (19)
```

The `@lexical/y` and `@lexical/yjs` unit suites pass, the randomized ones
included (`Fuzz`, `FollowUpFuzz`, `ConcurrentShapesFuzz`, `NestedFuzz`,
`SlotsFuzz`), also with `LEXICAL_Y_BLOCK_ID=1` for the new test and `Fuzz`:

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

`pnpm run ci-check` passes.
@vercel

vercel Bot commented Oct 29, 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 29, 2026 10:43pm UTC
lexical-playground Ready Ready Preview Oct 29, 2026 10:43pm 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 29, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 29, 2026

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: a37c2f8

This branch was successfully deployed

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