Skip to content

[lexical-mark] Bug Fix: Give text an input method composes over a selection the ids all of the replaced text had - #9540

Merged
zurfyx merged 3 commits into
mainfrom
zurfyx/fix-9519-mark-ime-replaced
Oct 30, 2026
Merged

zurfyx merged 3 commits into
mainfrom
zurfyx/fix-9519-mark-ime-replaced

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

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.

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 to getTextContent()). 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 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). 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).

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)

With the third commit, MarkStateExtension.test.ts 126/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.ts 121/121 (the named-slot and inline-element cases failed with expected 'abcd' to be 'abcde' before it). @lexical/mark and @lexical/y unit suites: 8,649 passed, 46 expected fail, 0 failed. pnpm run ci-check passes.

…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.
@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 3:08pm UTC
lexical-playground Ready Ready Preview Oct 30, 2026 3:08pm 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@9540

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 9371163

…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.

@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.

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:

  1. A selection holding only an inline decorator. $childrenText and $offsetInBlock (lines 1513-1580) count a leaf that is not text by its getTextContent(), which is '' for a DecoratorNode, so the selection spans no text and start === end passes at line 1611.
  2. 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 equals head + 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_PLACEHOLDERS entry that equals the selected text (before.text.slice(before.start, before.end));
  • count a leaf that is not text as one unit in $childrenText and $offsetInBlock, as $collectSegments does (or return null when end <= 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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).

Comment on lines +1642 to +1651
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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).

zurfyx added a commit that referenced this pull request Oct 30, 2026
…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).

@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.

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 on main too (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), and composeOverPlaceholder writes the composed text in front of a selected U+00A0 and keeps it. With a no-break space after the caret, $applyInsertedTextMarkIDs returns 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 example target.spliceText(start, end - start, composed, true) with start and end taken from the selection), makes that case fail on main ("abcd"[c1] "e") and pass here, so the Firefox and iOS placeholder branch is covered as well.

Comment on lines +2501 to +2506
target.spliceText(
at,
content[at] === '\u200b' ? 1 : 0,
composed,
true,
);

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 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.

@zurfyx
zurfyx added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 04e899d Oct 30, 2026
65 checks passed
zurfyx added a commit that referenced this pull request Oct 30, 2026
…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
```

This branch was successfully deployed

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

Bug: [lexical-mark] An IME replacement of a commented suffix leaves the new letters without the comment

1 participant