Repository navigation
[lexical-mark] Bug Fix: Give text an input method composes over a selection the ids all of the replaced text had - #9540
Conversation
…ection the ids all of the replaced text had ## Description `MarkStateExtension` gives text that replaces a selection the ids all of the replaced text had, so retyping a commented word keeps the comment. It trusts the replaced selection only when the text in its place is in new TextNodes, as the core inserts typed or pasted text, so that a handler that wrapped the selection is not taken for one that replaced it. An input method's text is written into the TextNode the caret is in, which existed before the composition. So at the end of the composition the selection was ignored, the change was found by comparing the texts alone, and letters composed past the end of a marked run were taken for text typed at the run's edge: retyping the commented `cd` of `abcd` as `cde` left `e` without the comment. The update that starts a composition now records whether it replaced the selection the composition started with: the selection is in one block, and that block's text afterwards is its text from before without the selection, with nothing or the core's placeholder in its place and the caret after it (or the placeholder selected), which is what the core's composition start does (`$handleCompositionStart`, through `CONTROLLED_TEXT_INSERTION_COMMAND` or `$prepareCompositionTarget`). At the end of the composition, a selection seen replaced that way is known to be replaced and skips the new TextNode check; the other checks on where it is stay. Anything else falls back to the comparison as before: a selection an application handler moved before the composition started, an insertion of the placeholder a handler did another way (wrapping the selection), a selection across blocks. Typed and pasted text are unchanged. A browser fires `compositionstart` as an event of its own, before the input method writes, so the start is its own update. A test that starts the composition and writes the composed text in one update, as the issue's repro does, shows no replaced selection to record and keeps the previous result; the same repro with the start in its own update passes. Closes #9519 ## Changeset `.changeset/mark-state-composition-replaces-selection.md` (`@lexical/mark`, `[fix]`). ## Test plan New `composition over a selection` cases in `MarkStateExtension.test.ts`: the issue's case, the same with plain text after the run, two cases that already passed (a word inside a run, a selection only partly in the run), and two where the selection is not replaced and the ids must stay as they were (a composition start handler collapses the selection; a handler of the placeholder insertion quotes the selection instead). ``` pnpm exec vitest run --project unit --reporter=verbose packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts -t "composition over a selection" ``` ### Before ``` × |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > replacing the end of a run, by longer text gives the text the ids all of the replaced text had × |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > replacing the end of a run followed by plain text, by longer text gives the text the ids all of the replaced text had ✓ |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > replacing a word inside a run gives the text the ids all of the replaced text had ✓ |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > replacing text only partly in the run gives the text the ids all of the replaced text had ✓ |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > a selection that a handler collapsed before the composition started was not replaced ✓ |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > a selection that a handler of the composition placeholder kept was not replaced ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ AssertionError: expected 'paragraph("abcd"[c1] "e")' to be 'paragraph("abcde"[c1])' // Object.is equality Expected: "paragraph("abcde"[c1])" Received: "paragraph("abcd"[c1] "e")" AssertionError: expected 'paragraph("abcd"[c1] "e xy")' to be 'paragraph("abcde"[c1] " xy")' // Object.is equality Expected: "paragraph("abcde"[c1] " xy")" Received: "paragraph("abcd"[c1] "e xy")" Test Files 1 failed (1) Tests 2 failed | 4 passed | 112 skipped (118) ``` ### After ``` ✓ |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > replacing the end of a run, by longer text gives the text the ids all of the replaced text had ✓ |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > replacing the end of a run followed by plain text, by longer text gives the text the ids all of the replaced text had ✓ |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > replacing a word inside a run gives the text the ids all of the replaced text had ✓ |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > replacing text only partly in the run gives the text the ids all of the replaced text had ✓ |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > a selection that a handler collapsed before the composition started was not replaced ✓ |unit| packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts > MarkStateExtension > composition over a selection > a selection that a handler of the composition placeholder kept was not replaced Test Files 1 passed (1) Tests 6 passed | 112 skipped (118) ``` `@lexical/mark` and `@lexical/y` unit suites: 181 files, 8,648 passed, 46 expected fail, 0 failed. `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: |
…laced also in a block with a named slot or an inline element that adds text
## Description
The previous commit gives text that an input method composes over a selection the ids all of the replaced text had, by checking that the update that started the composition replaced the selection: the block's text must be the old text without the selection, with nothing or the core's placeholder in its place. It counted the selection's offsets in the block's children (`$offsetInBlock`, which walks into inline elements and adds up their children's text) but compared them with `getTextContent()`, which differs from that text in two cases:
- a block that declares a named slot (a paragraph with a title slot, say): `getTextContent()` puts the slot's text in front of the children's;
- an inline element whose `getTextContent()` adds text of its own (brackets around its children, say).
In both, the offsets never matched, the replaced selection was not recognized, and composed text over a selection in a commented word lost the comment: "abcd" marked c1, "cd" composed as "cde" ended as "abcd"[c1] "e".
The check now compares the children's text as `$offsetInBlock` counts it (`$childrenText`: an inline element's text is its children's, and slots are left out). A selection inside a slot's own block was not affected, since that block has no slots of its own; a test covers both. A block that holds a nested block before the selection is still left out, as before (`$offsetInBlock` gives -1 there), so composed text there keeps only the ids of the run it is typed in.
## Test plan
### Before
```
$ vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts -t 'named slot'
× replacing the end of a run in its children gives the text the ids all of the replaced text had 46ms
✓ replacing the end of a run in its slot gives the text the ids all of the replaced text had 5ms
AssertionError: expected 'abcd' to be 'abcde' // Object.is equality
Tests 1 failed | 1 passed | 118 skipped (120)
$ vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts -t 'inline element that adds text'
× replacing the end of a run inside an inline element that adds text of its own gives the text the ids all of the replaced text had 49ms
AssertionError: expected 'abcd' to be 'abcde' // Object.is equality
Tests 1 failed | 120 skipped (121)
```
### After
```
$ vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts
Test Files 1 passed (1)
Tests 121 passed (121)
```
`@lexical/mark` and `@lexical/y` unit projects: 8649 passed, 46 expected fail. `pnpm run ci-check` passes.
There was a problem hiding this comment.
Thanks for this: the composition path now does what packages/lexical-website/docs/collaboration/comments.md already documents for a replaced selection, and the new end-of-run, named-slot and inline-element cases fail without the change and pass with it.
Must fix: a selection the composition start kept can still pass as replaced
$getCompositionReplacedRange trusts the start update whenever the block's text is head + placeholder + tail with the caret after the placeholder (packages/lexical-mark/src/MarkStateExtension.ts:1642-1651). Two kinds of selection that the start kept also satisfy that, and with replacedForSure the text composed after them then takes their ids:
- A selection holding only an inline decorator.
$childrenTextand$offsetInBlock(lines 1513-1580) count a leaf that is not text by itsgetTextContent(), which is''for aDecoratorNode, so the selection spans no text andstart === endpasses at line 1611. - A selection that is exactly one U+00A0 or U+200B, when the start writes nothing (the desktop Safari path through
$prepareCompositionTarget): the unchanged text then equalshead + selected + tail.
Repro with the handler from your test "a selection that a handler collapsed before the composition started was not replaced" (COMMAND_PRIORITY_HIGH, collapse to the focus, return false), then compose X:
| Document and selection | Before this PR | With this PR |
|---|---|---|
"ab" + inline decorator [c1] + "cd", selection (paragraph, 1) to (paragraph, 2) |
"ab" decorator[c1] "Xcd" |
"ab" decorator[c1] "X"[c1] "cd" |
a + U+00A0 + b, c1 on all of it, c2 on the space, the space selected |
X gets [c1] |
X gets [c1, c2] |
That contradicts the documented rule that text inserted at the edge of a run does not join it, and the description's statement that a selection an application handler moved falls back to the comparison. Both parts of the fix are needed:
- skip a non-empty
COMPOSITION_PLACEHOLDERSentry that equals the selected text (before.text.slice(before.start, before.end)); - count a leaf that is not text as one unit in
$childrenTextand$offsetInBlock, as$collectSegmentsdoes (or returnnullwhenend <= start).
A negative test for each, next to the collapsed-selection one, would keep them fixed.
Consider: cover the other two placeholders
jsdom always takes the zero-width-space branch of $handleCompositionStart, so the empty (desktop Safari) and no-break-space (Firefox, iOS) entries of COMPOSITION_PLACEHOLDERS are never reached by the new tests. A test.each over the three, with a higher-priority CONTROLLED_TEXT_INSERTION_COMMAND handler that calls $prepareCompositionTarget(selection) or selection.insertText('\u00a0') for the placeholder payload, would cover them.
| } | ||
| const start = $offsetInBlock(block, range[0]); | ||
| const end = $offsetInBlock(block, range[1]); | ||
| return start < 0 || end < start |
There was a problem hiding this comment.
A selection that holds only an inline decorator gets start === end here: $childrenText and $offsetInBlock count a leaf that is not text by its getTextContent(), which is '' for a DecoratorNode. If a COMPOSITION_START_COMMAND handler collapses such a selection to its end, the unchanged block still matches head + placeholder + tail below, and the letter composed after the kept decorator takes its ids ("ab" decorator[c1] "X"[c1] "cd" where the base gives "Xcd"). Counting a non-text leaf as one unit in both helpers, as $collectSegments does, or returning null when end <= start, avoids it.
There was a problem hiding this comment.
Fixed in 9371163: a child that is not text (a decorator, a line break) now counts as one unit in both $childrenText and $offsetInBlock, so a selection of an inline decorator without text spans one unit; a kept one no longer matches. New test: "a selection of an inline decorator without text that a handler kept was not replaced" (fails on 9e1c826: the composed X took c1).
| for (const placeholder of COMPOSITION_PLACEHOLDERS) { | ||
| const end = before.start + placeholder.length; | ||
| if ( | ||
| text === head + placeholder + tail && | ||
| Math.max(anchor, focus) === end && | ||
| (Math.min(anchor, focus) === end || | ||
| Math.min(anchor, focus) === before.start) | ||
| ) { | ||
| return before.range; | ||
| } |
There was a problem hiding this comment.
When the start writes nothing (desktop Safari's $prepareCompositionTarget path), a kept selection that is exactly one U+00A0 or U+200B also matches: the unchanged text is head + selected + tail, which equals head + placeholder + tail for that entry, so the text composed after it takes the space's ids. Skipping a non-empty placeholder equal to before.text.slice(before.start, before.end) sends that ambiguous case back to the comparison.
There was a problem hiding this comment.
Fixed in 9371163: a non-empty placeholder equal to the selected text is skipped, so a kept no-break or zero-width space selection with a start that wrote nothing falls back to the comparison. New test: "a selection that is a no-break space, kept by a start that wrote nothing, was not replaced" (fails on 9e1c826: X took c1 and c2). The replaced case is now covered for all three placeholders (nothing, a zero-width space, a no-break space).
…s that #9573 fixed ## Description #9573 merged, so the slot-block list numbering cells pass on main: the 179 cells marked `test.fails` for it (139 in `C1ReadEqualsFresh.test.ts`, 16 in `C1Busy.test.ts`, the 24 seeds of `CatalogSoakFuzz.test.ts`) now report as passing unexpectedly. They are plain tests again. The other expected failures (#9553, #9568, #9540, #9581) stay. ## Test plan ``` pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix pnpm run ci-check ``` ### Before On main `cad9015` with the contracts as they were: ``` ❯ packages/lexical-y/src/__tests__/unit/matrix/CatalogSoakFuzz.test.ts (24 tests | 24 failed) ❯ packages/lexical-y/src/__tests__/unit/matrix/C1Busy.test.ts (96 tests | 16 failed) ❯ packages/lexical-y/src/__tests__/unit/matrix/C1ReadEqualsFresh.test.ts (435 tests | 139 failed) Tests 179 failed | 1973 passed | 32 expected fail (2184) ``` ### After On main `cad9015`: ``` Test Files 12 passed (12) Tests 2152 passed | 32 expected fail (2184) $ pnpm run ci-check Found 0 errors ```
…start kept for a replaced one when it is a decorator or a placeholder character
## Description
The check that a composition start replaced the selection (the block's text is the text from before without the selection, with nothing or the core's placeholder in its place, and the caret after it) could also pass for a selection the start kept, in two cases, and the text composed after it then took the selection's ids, against the rule that text typed at the edge of a run does not join it:
- **A selection holding only an inline decorator without text.** The children's text counted a decorator by its `getTextContent()`, which is usually empty, so the selection spanned no text and matched. A child that is not text (a decorator, a line break) now counts as one unit in both `$childrenText` and `$offsetInBlock`.
- **A selection that is exactly one no-break space or zero-width space, kept by a start that wrote nothing** (as a browser composing in place does): the unchanged text equals the text without the selection plus that placeholder. A placeholder equal to the selected text is now skipped, so that case falls back to the comparison.
New tests: both cases (a handler collapses the selection at the composition start; X composed after a decorator marked c1 stays unmarked, and after a no-break space marked c1 and c2 gets only c1), and the replaced case with each of the three placeholders (nothing, a zero-width space, a no-break space), which jsdom's default path covers only for the zero-width space.
## Test plan
### Before
```
$ vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts -t 'composition over a selection'
× a selection of an inline decorator without text that a handler kept was not replaced 6ms
× a selection that is a no-break space, kept by a start that wrote nothing, was not replaced 3ms
AssertionError: expected [ 'c1' ] to deeply equal []
AssertionError: expected [ 'c1', 'c2' ] to deeply equal [ 'c1' ]
```
### After
```
$ vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts
Test Files 1 passed (1)
Tests 126 passed (126)
```
`@lexical/mark` and `@lexical/y` unit projects: 8654 passed, 46 expected fail. `pnpm run ci-check` passes (eslint uncached, `tsc -p tsconfig.test.json` clean).
There was a problem hiding this comment.
Thanks, this looks good. Text an input method composes over a selection now gets the ids all of the replaced text had, which is what packages/lexical-website/docs/collaboration/comments.md already promises (its Inserted text section, and the Limitations note that the replaced selection is known when an insertion starts an input method composition). Both kept-selection cases from the previous round now fall back to the comparison: a selection holding only an inline decorator, and a lone U+00A0 or U+200B kept by a start that writes nothing. The two new negative tests fail against 9e1c826a and pass here, and in Chromium with a real input method, retyping the commented cd of abcd as cde keeps all five letters in the comment, where main leaves e out.
One optional, non-blocking suggestion:
- The no-break-space case of
a selection replaced by %s at the composition start…passes onmaintoo (packages/lexical-mark/src/__tests__/unit/MarkStateExtension.test.ts:2489-2548). The core's composition start leaves the placeholder selected (anchor before it, focus after it), andcomposeOverPlaceholderwrites the composed text in front of a selected U+00A0 and keeps it. With a no-break space after the caret,$applyInsertedTextMarkIDsreturns early on both branches, which is why the assertion strips\u00a0. Writing over the selection instead, as an input method does with a selected range (for exampletarget.spliceText(start, end - start, composed, true)withstartandendtaken from the selection), makes that case fail onmain("abcd"[c1] "e") and pass here, so the Firefox and iOS placeholder branch is covered as well.
| target.spliceText( | ||
| at, | ||
| content[at] === '\u200b' ? 1 : 0, | ||
| composed, | ||
| true, | ||
| ); |
There was a problem hiding this comment.
Optional: the core's composition start leaves the placeholder selected (anchor before it, focus after it), so for U+00A0 this branch writes the composed text in front of it and keeps it. The no-break space after the caret then makes $applyInsertedTextMarkIDs return early, and the U+00A0 case passes on main as well. Writing over the selection instead (spliceText(start, end - start, composed, true) with start and end from the selection), as an input method does, makes it fail on main ("abcd"[c1] "e") and pass here.
…and count each id's holders in the HTML import check ## Description #9540 and #9584 merged, so the cells marked `test.fails` for them pass on main and report as passing unexpectedly: letters composed past the end of a marked run (26 cells in `MarksIME.test.ts`) and the `@lexical/yjs` v2 table row cell of #9581 (`C6Settles.test.ts`, `yjs-v2, table edits, follow-ups none, repair none, seed 11`). They are plain tests again. The marks for #9553, #9568, #9578 (and #9561) and #9606 stay. The import check of `C7Html.test.ts` collected the imported blocks' ids in a map, so two blocks holding the same id counted as one and "exactly one imported block" was never asserted. It now keeps every holder and requires exactly one, of the same type. ## Test plan ``` pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/matrix pnpm run ci-check ``` ### Before On main `04e899d` with the marks: ``` ❯ packages/lexical-y/src/__tests__/unit/matrix/MarksIME.test.ts (56 tests | 26 failed) ❯ packages/lexical-y/src/__tests__/unit/matrix/C6Settles.test.ts (360 tests | 1 failed) ``` ### After On main `04e899d`: ``` Test Files 13 passed (13) Tests 2395 passed | 13 expected fail (2408) $ pnpm run ci-check Found 0 errors ```
Description
MarkStateExtensiongives text that replaces a selection the ids all of the replaced text had, so retyping a commented word keeps the comment. It trusts the replaced selection only when the text in its place is in new TextNodes, as the core inserts typed or pasted text, so that a handler that wrapped the selection is not taken for one that replaced it. An input method's text is written into the TextNode the caret is in, which existed before the composition. So at the end of the composition the selection was ignored, the change was found by comparing the texts alone, and letters composed past the end of a marked run were taken for text typed at the run's edge: retyping the commentedcdofabcdascdeleftewithout the comment.The update that starts a composition now records whether it replaced the selection the composition started with: the selection is in one block, and that block's text afterwards is its text from before without the selection, with nothing or the core's placeholder in its place and the caret after it (or the placeholder selected), which is what the core's composition start does (
$handleCompositionStart, throughCONTROLLED_TEXT_INSERTION_COMMANDor$prepareCompositionTarget). At the end of the composition, a selection seen replaced that way is known to be replaced and skips the new TextNode check; the other checks on where it is stay. Anything else falls back to the comparison as before: a selection an application handler moved before the composition started, an insertion of the placeholder a handler did another way (wrapping the selection), a selection across blocks. Typed and pasted text are unchanged.The check counts the selection's offsets in the block's children, as the block's text without its named slots (which
getTextContent()puts in front) and with an inline element's text being its children's (an inline element can add text of its own togetTextContent()). A block that holds a nested block before the selection is left out (the comparison falls back as before there). A child that is not text (a decorator, a line break) counts as one unit, so a selection of an inline decorator without text cannot pass as replaced when a handler kept it; and a placeholder equal to the selected text is skipped, so a kept no-break or zero-width space with a start that wrote nothing falls back to the comparison (third commit).A browser fires
compositionstartas an event of its own, before the input method writes, so the start is its own update. A test that starts the composition and writes the composed text in one update, as the issue's repro does, shows no replaced selection to record and keeps the previous result; the same repro with the start in its own update passes.Closes #9519
Changeset
.changeset/mark-state-composition-replaces-selection.md(@lexical/mark,[fix]).Test plan
New
composition over a selectioncases inMarkStateExtension.test.ts: the issue's case, the same with plain text after the run, two cases that already passed (a word inside a run, a selection only partly in the run), and two where the selection is not replaced and the ids must stay as they were (a composition start handler collapses the selection; a handler of the placeholder insertion quotes the selection instead). Also: a block with a named slot (the selection in its children, and in the slot's own block) and an inline element that adds text of its own (both failed before the second commit, which compares the children's text the way the offsets are counted).Before
After
With the third commit,
MarkStateExtension.test.ts126/126 (the kept-decorator and kept-no-break-space cases failed before it; the replaced case is covered for all three placeholders). With the second commit,MarkStateExtension.test.ts121/121 (the named-slot and inline-element cases failed withexpected 'abcd' to be 'abcde'before it).@lexical/markand@lexical/yunit suites: 8,649 passed, 46 expected fail, 0 failed.pnpm run ci-checkpasses.