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

Queued
zurfyx wants to merge 3 commits into
mainfrom
zurfyx/fix-s8-7-composition-offset
Queued

zurfyx wants to merge 3 commits 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.

Follow-up (967b41c): a node kept apart is not split around the composition

With the composed node placed around the composition's character, a node Lexical keeps apart from an equal neighbour (stored as a run with a segment key: a hashtag, token or segmented text, a code highlight token, a custom TextNode subclass) whose text before the caret a collaborator grew was split in two parts with one segment key. The part before took the composed node through the reader's lookup by segment key, and the composed part took it back, so that text was not shown (the composer's editor read kd44] かnn where the document holds [kd44] かnn, the self-check reported the line, and the difference stayed after the composition ended). Even with the composed node reserved for its own part, the two parts would be two nodes of one segment that Lexical does not merge when the composition ends. A run with a segment key is now never split around the composition: the composed node is the whole run, as on main. For such a node the browser's offset is not adjusted when text before it inside the node grows (as on main); plain text keeps this PR's offset.

New test in CompositionInterleave.test.ts: A composes at the end of a hashtag while B replaces a character before A's caret with two; A's line shows the whole hashtag while composing and after, and the peers converge (both client id orders; passes on main).

Before (42c53ff)

   ✓ @lexical/y a composition at text a collaborator typed next to at the same time (2)
     ✓ the composed text stays after the composer's own text (ids 1,2) 39ms
     ✓ the composed text stays after the composer's own text (ids 2,1) 19ms
     × the text before the composed node stays shown (ids 1,2) 11ms
     × the text before the composed node stays shown (ids 2,1) 9ms
Failed Tests 2
      Tests  2 failed | 2 passed (4)

After (967b41c)

 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts (4 tests) 105ms
      Tests  4 passed (4)

@lexical/y and @lexical/hashtag unit suites: 7,488 passed, 46 expected fail. pnpm run ci-check passes. Randomized four-client sessions with compositions (catalog documents): two recorded sessions that showed an editor differing from a fresh read of its own document on 42c53ff (one at a step with no composition open) show none on 967b41c, and a reduced five-step session that failed on 42c53ff combined with #9573 passes on 967b41c combined with #9573; 100 sessions of the same lane give the same results on 967b41c and on this PR's base.

… 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 30, 2026 5:28pm UTC
lexical-playground Ready Ready Preview Oct 30, 2026 5:28pm 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: 328dc83

…from its neighbours shown when a collaborator's change grows it

## Description

While a client composes, the reader now places the composed node around the
composition's character, with as many characters before and after it as the
node had. For a node Lexical keeps apart from an equal neighbour (stored as a
run with a segment key: a hashtag, token or segmented text, a custom
`TextNode` subclass), a collaborator's change that adds text before the
caret inside that node (and deletes some, so it is read at once) split the
run in two parts with one segment key. The part before the composed node
then took the composed node itself (the reader's lookup by segment key did
not leave it to the composed part, as the lookup for plain text does), and
the composed part took it back, so that text was not shown: the composer's
editor read `kd44] かnn` where the document holds `[kd44] かnn`, the
self-check reported the line, and the editor kept the difference after the
composition ended, so the composer's next edit there landed one character
away from where they saw it.

A run with a segment key is no longer split around the composition: its
parts would be two nodes of one segment, which Lexical does not merge when
the composition ends, so even with the composed node reserved for its own
part (as the plain-text lookup does) the editor would keep two nodes where
the document holds one. The composed node is the whole run, as before the
composition offset change. For such a node, the browser's offset in the node
stays where it was when a collaborator adds text before it inside the node
(as on main); plain text keeps its offset right after the composer's
character.

## Test plan

New test in `CompositionInterleave.test.ts`: A composes at the end of a
hashtag while B replaces a character before A's caret with two; A's line
shows the whole hashtag while composing and after, and the peers converge
(both client id orders). It passes on main, where the run is not split.

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

### Before

```
   ✓ @lexical/y a composition at text a collaborator typed next to at the same time (2)
     ✓ the composed text stays after the composer's own text (ids 1,2) 39ms
     ✓ the composed text stays after the composer's own text (ids 2,1) 19ms
     × the text before the composed node stays shown (ids 1,2) 11ms
     × the text before the composed node stays shown (ids 2,1) 9ms
 Failed Tests 2
AssertionError: expected 'x cdb' to be 'x #cdb' // Object.is equality
      Tests  2 failed | 2 passed (4)
```

### After

```
 ✓ |unit| packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts (4 tests) 105ms
      Tests  4 passed (4)
```

`@lexical/y` and `@lexical/hashtag` unit suites: 7,488 passed, 46 expected
fail, 0 failed. `pnpm run ci-check` passes. A recorded four-client session
with compositions (43 steps) that produced the self-check report
`editor "kd44] かnn" != shared "[kd44] かnn"` on a custom text node class
replays with no report, and the composer's editor equals a fresh read of its
document after the composition ends and after a further edit. Reserving the
composed node for its own part in the lookup by segment key (a separate
change) is not needed for this case: the run is no longer split.

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good. Placing the composed node around the composer's own last character, instead of the first place its text appears in the line, is the right anchor, and never splitting a run with a segment key keeps hashtags and other kept-apart nodes whole. Two optional, non-blocking suggestions:

  1. Cover the path a real composition takes (packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts, lines 61-69). Both new tests set the composition key before any composed text exists, so main fails through the first-occurrence search. Once an input method has written its first string, the composed node's text no longer appears in the line, and main used the fallback this PR removes. In the collab-Y playground in Chromium, with the composition driven over CDP the way the Collab Y: IME specs in CollabYNodes.spec.mjs do it and the collaborator's text ordered first, main ends with the collaborator's word deleted (both clients converge on t12dz t18ez tka), while this branch keeps t12dz t18ez t20ez tka. A variant of the first test that writes 'k' with node.spliceText(offset, 0, 'k', true) before t.sync() and then replaces it with node.spliceText(offset, 1, 'ka', true) fails on main for ids 2,1 and passes here; a collab-Y e2e spec (pnpm run test-e2e-collab-y-chromium, as AGENTS.md describes) would cover the browser side.
  2. Stale comment (packages/lexical-y/src/internal/Read.ts, lines 2416-2419): it still says the composed node "keeps its key and its text". It now keeps its key but takes the text around the composition's character, so folding that sentence into the new comment at lines 2432-2440 would keep the two consistent.

Unrelated to this change: with the composer's text ordered first, both main and this branch keep the first composition string (the line converges on t12dz t18ez tkka t20ez). The composed node keeps its text on that path, so it looks like a separate follow-up rather than something for this PR.

const sameType = (piece: Piece): piece is Piece & {kind: 'text'} =>
piece.kind === 'text' &&
(piece.tattrs[TYPE_KEY] ?? 'text') === composed.getType();
// Where the composition's character is known (see composedAt), the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: the comment just above this block (lines 2416-2419) still says the composed node "keeps its key and its text". With this change it keeps its key but takes the text around the composition's character, which can differ from its old text, so it may be worth folding that sentence into this comment.

}
offset = node.getTextContentSize();
node.select(offset, offset);
$setCompositionKey(node.getKey());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: the composition starts here with no composed text, so main fails through the first-occurrence search. A real input method has already written its first string when the collaborator's change arrives, and then main takes the fallback this PR removes. Writing 'k' here with node.spliceText(offset, 0, 'k', true) and replacing it after the sync with node.spliceText(offset, 1, 'ka', true) (expecting t12dz t18ez t20ez tka or t12dz t18ez tka t20ez) fails on main for ids 2,1 and passes on this branch.

## Description

Merges `main` (cad9015) into this branch to resolve the conflict in
`packages/lexical-y/src/internal/Read.ts` with #9580 ("Keep the composed
node for its own piece"). Resolution: this branch's removal of the fallback
loop that marked a caret piece as composed stays removed, and #9580's
reservation of the composed node (`usedTexts.add(composed)` when it was
placed) is kept after this branch's placement loop. No other change.

## Test plan

```
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Composition.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBack.test.ts packages/lexical-y/src/__tests__/unit/CompositionHoldBackBlockIds.test.ts packages/lexical-y/src/__tests__/unit/CompositionInterleave.test.ts packages/lexical-y/src/__tests__/unit/regressions/Composition.test.ts packages/lexical-y/src/__tests__/unit/regressions/CompositionCodeSegments.test.ts
```

### Before

`git merge main` stops with a conflict in `Read.ts`.

### After

```
 Test Files  6 passed (6)
      Tests  2066 passed | 2 skipped (2068)
```

```
pnpm exec vitest run --project unit packages/lexical-y packages/lexical-hashtag
 Test Files  169 passed | 2 skipped (171)
      Tests  7510 passed | 46 expected fail | 4 skipped (7560)
```

`pnpm run ci-check` passes.

This branch was successfully deployed

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