Repository navigation
Conversation
… 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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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: |
…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.
There was a problem hiding this comment.
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:
- 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, somainfails 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, andmainused the fallback this PR removes. In the collab-Y playground in Chromium, with the composition driven over CDP the way theCollab Y: IMEspecs inCollabYNodes.spec.mjsdo it and the collaborator's text ordered first,mainends with the collaborator's word deleted (both clients converge ont12dz t18ez tka), while this branch keepst12dz t18ez t20ez tka. A variant of the first test that writes'k'withnode.spliceText(offset, 0, 'k', true)beforet.sync()and then replaces it withnode.spliceText(offset, 1, 'ka', true)fails onmainfor 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. - 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 |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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.
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 twhere the composed node wast12dz t18ez t(the composer's owntat its end). The first occurrence oft12dz t18ez tends in the collaborator'st, 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 typestat the end of a line and composes after it while B typest20ezat the same place and deletes the line's bold word in the same update; after the input method writes3at its offset, both words are whole and the peers converge (both client id orders; the order where B's text comes first failed).Before
After
@lexical/yunit suite (includingComposition,CompositionHoldBackandCompositionHoldBackBlockIds): 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-checkpasses.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 compositionWith 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
TextNodesubclass) 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 readkd44] かnnwhere 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)After (
967b41c)@lexical/yand@lexical/hashtagunit suites: 7,488 passed, 46 expected fail.pnpm run ci-checkpasses. Randomized four-client sessions with compositions (catalog documents): two recorded sessions that showed an editor differing from a fresh read of its own document on42c53ff(one at a step with no composition open) show none on967b41c, and a reduced five-step session that failed on42c53ffcombined with #9573 passes on967b41ccombined with #9573; 100 sessions of the same lane give the same results on967b41cand on this PR's base.