Skip to content

[lexical-y] Bug Fix: Keep a composition going on after the composer's own text when a collaborator's text comes in before it - #9555

Open
zurfyx wants to merge 1 commit into
mainfrom
zurfyx/fix-s8-7-composition-offset
Open

zurfyx wants to merge 1 commit into
mainfrom
zurfyx/fix-s8-7-composition-offset

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Description

While a client composes (IME), the reader keeps the node being composed with its key and its text, so the browser goes on composing in its DOM text node. It found that text by string: the first occurrence of the node's old text in the line. When a collaborator typed at the same place at the same time as the composer's own text, and their text is ordered before it, the line reads, for example, t12dz t18ez t20ez t where the composed node was t12dz t18ez t (the composer's own t at its end). The first occurrence of t12dz t18ez t ends in the collaborator's t, so the composed node stood for the collaborator's characters, and the next character the input method wrote at its offset in the node landed inside the collaborator's word: t12dz t18ez t320ez t. Nothing was lost or duplicated, but a word was cut. The composition's change is normally held back until it ends; here the collaborator's change also deleted other text of the line, and a change that deletes shown text is read whole.

The reader already knows the composition's character by identity (composedAt, the character before the caret). It now places the node around that character: as many characters before and after it as the node had, so the browser's offset in the node's DOM text stays right after the composer's own text. Another occurrence of the node's text elsewhere holds other characters and is not the node. Without a known character (no caret in the node) the node is found by its text as before. One case keeps the offset off by one: where the node's start would fall between the two halves of an astral character a collaborator put there, the node starts before the character (a node never splits a surrogate pair), so the browser's offset is one unit before the composer's character; main misplaces it there too. This also replaces the previous fallback for a node whose text is no longer there whole (a collaborator deleted part of it), which kept its key for the whole run of text holding the character and so moved the browser's offset; the node keeps the text around the character instead.

Changeset

.changeset/lexical-y-composition-offset.md (@lexical/y, [fix]).

Test plan

New CompositionInterleave.test.ts: A types t at the end of a line and composes after it while B types t20ez at the same place and deletes the line's bold word in the same update; after the input method writes 3 at its offset, both words are whole and the peers converge (both client id orders; the order where B's text comes first failed).

pnpm exec vitest run --project unit --reporter=verbose packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts

Before

 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts > @lexical/y a composition at text a collaborator typed next to at the same time > the composed text stays after the composer's own text (ids 1,2)
 × |unit| packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts > @lexical/y a composition at text a collaborator typed next to at the same time > the composed text stays after the composer's own text (ids 2,1)
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected [ 't12dz t18ez t20ez t3', …(1) ] to include 't12dz t18ez t320ez t'
 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)

After

 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts > @lexical/y a composition at text a collaborator typed next to at the same time > the composed text stays after the composer's own text (ids 1,2)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts > @lexical/y a composition at text a collaborator typed next to at the same time > the composed text stays after the composer's own text (ids 2,1)
 Test Files  1 passed (1)
      Tests  2 passed (2)

@lexical/y unit suite (including Composition, CompositionHoldBack and CompositionHoldBackBlockIds): 7,486 passed, 46 expected fail, 0 failed. A randomized two-to-twelve-client session harness with compositions, schema versions and agents (12,000 sessions, same seeds with and without this change): the same flag counts on both, no crash. pnpm run ci-check passes.

Known residual (not changed here)

A randomized collaboration soak reached one case of an existing class (a composition in a highlighted code line while remote edits arrive) through this change's different trace: when the composition ends, the composer's editor shows the composed word while the stored line holds its characters in another order; the opt-in self-check reports it. The class is the known composition-in-highlighted-code limit; its overall rate went down on the soak with this change (28 sessions only with it vs 67 only without). Not reduced yet; a follow-up.

… own text when a collaborator's text comes in before it

## Description

While a client composes (IME), the reader keeps the node being composed with its key and its text, so the browser goes on composing in its DOM text node. It found that text by string: the first occurrence of the node's old text in the line. When a collaborator typed at the same place at the same time as the composer's own text, and their text is ordered before it, the line reads, for example, `t12dz t18ez t20ez t` where the composed node was `t12dz t18ez t` (the composer's own ` t` at its end). The first occurrence of `t12dz t18ez t` ends in the collaborator's ` t`, so the composed node stood for the collaborator's characters, and the next character the input method wrote at its offset in the node landed inside the collaborator's word: `t12dz t18ez t320ez t`. Nothing was lost or duplicated, but a word was cut. The composition's change is normally held back until it ends; here the collaborator's change also deleted other text of the line, and a change that deletes shown text is read whole.

The reader already knows the composition's character by identity (`composedAt`, the character before the caret). It now places the node around that character: as many characters before and after it as the node had, so the browser's offset in the node's DOM text stays right after the composer's own text. Another occurrence of the node's text elsewhere holds other characters and is not the node. Without a known character (no caret in the node) the node is found by its text as before. One case keeps the offset off by one: where the node's start would fall between the two halves of an astral character a collaborator put there, the node starts before the character (a node never splits a surrogate pair), so the browser's offset is one unit before the composer's character; main misplaces it there too. This also replaces the previous fallback for a node whose text is no longer there whole (a collaborator deleted part of it), which kept its key for the whole run of text holding the character and so moved the browser's offset; the node keeps the text around the character instead.

## Changeset

`.changeset/lexical-y-composition-offset.md` (`@lexical/y`, `[fix]`).

## Test plan

New `CompositionInterleave.test.ts`: A types ` t` at the end of a line and composes after it while B types ` t20ez` at the same place and deletes the line's bold word in the same update; after the input method writes `3` at its offset, both words are whole and the peers converge (both client id orders; the order where B's text comes first failed).

```
pnpm exec vitest run --project unit --reporter=verbose packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts
```

### Before

```
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts > @lexical/y a composition at text a collaborator typed next to at the same time > the composed text stays after the composer's own text (ids 1,2)
 × |unit| packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts > @lexical/y a composition at text a collaborator typed next to at the same time > the composed text stays after the composer's own text (ids 2,1)
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected [ 't12dz t18ez t20ez t3', …(1) ] to include 't12dz t18ez t320ez t'
 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
```

### After

```
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts > @lexical/y a composition at text a collaborator typed next to at the same time > the composed text stays after the composer's own text (ids 1,2)
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts > @lexical/y a composition at text a collaborator typed next to at the same time > the composed text stays after the composer's own text (ids 2,1)
 Test Files  1 passed (1)
      Tests  2 passed (2)
```

`@lexical/y` unit suite (including `Composition`, `CompositionHoldBack` and `CompositionHoldBackBlockIds`): 7,486 passed, 46 expected fail, 0 failed. A randomized two-to-twelve-client session harness with compositions, schema versions and agents (12,000 sessions, same seeds with and without this change): the same flag counts on both, no crash. `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 11:56pm UTC
lexical-playground Ready Ready Preview Oct 29, 2026 11:56pm 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@9555

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 42c53ff

This branch was successfully deployed

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

Development

Successfully merging this pull request may close these issues.

1 participant