Skip to content

[lexical-code-prism][lexical-code-shiki][lexical-mark][lexical-website][lexical-y] Bug Fix: Keep comment ids and NodeState when a block change moves or re-creates text - #9503

Merged
zurfyx merged 1 commit into
mainfrom
zurfyx/keep-node-state-on-block-convert
Oct 29, 2026

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 28, 2026

Copy link
Copy Markdown
Member

Description

MarkStateExtension gives inserted text its comment ids with a root transform
that compares the blocks around the caret with the previous editor state. That
comparison only covered the sibling blocks of the caret's block and treated a
nested block (the items of a list) as one opaque unit. Text that a block
change moved in from outside it had no counterpart, so it was taken for text
typed into an existing node: it lost its own ids and got the ids shared by
its neighbours. The core keeps the NodeState in these conversions; with the
transform disabled, the ids survive.

  • Turning a lone list item into a paragraph, quote or heading
    ($setBlocksType) removed every comment id from its text, with or without
    @lexical/y.
  • Turning a paragraph into a list item that joins the list above it
    ($insertList) removed the comment from a link when the item above ended
    with the same text, or gave the moved text the comment of the item above
    (Bug: [lexical-mark] Converting a paragraph to a list changes MarkStateExtension comment ids on text that did not change #9490).
  • Converting a list item to a highlighted code block, which re-creates the
    text in new nodes, lost its ids too.
  • Turning a highlighted code block into a paragraph lost them whenever the
    inserted-text rule could not run (with the code selected, or for pasted
    code): Prism and Shiki replace each code highlight node that leaves a code
    block with $createTextNode(text), which drops its NodeState.

The comparison now:

  • keeps the ids of a leaf that existed before the update and kept its text,
    wherever the update moved it, and of the text a moved TextNode kept at each
    end when the same update also typed into it (each run of inserted units
    between kept ones is an insertion, with the ids shared by its neighbours);
  • compares a nested block that the update changed by its text, like the
    blocks around it (an unchanged one stays a single unit, so an edit next to
    a large nested list does not read it);
  • also compares the following block when it lost its first children, as a
    list does when its first item is converted.

@lexical/code-prism and @lexical/code-shiki now copy the NodeState of a
code highlight node (flat states included) into the TextNode that replaces
it, keeping any state that a TextNode replacement sets, so any NodeState
survives leaving a code block, not only comment ids.

A node that code moves (rather than creates) into the middle of a marked run
now keeps exactly its own ids instead of taking the run's; comments.md says
so, and that block changes keep every id.

Independent of #9489 and #9496. It merges with #9489 without conflicts of its
own (that PR's comments.md conflict with main is unrelated). With #9496 it
has two one-line conflicts in MarkStateExtension.ts, at the two
$collectSegments calls: #9496 adds a flavor first argument and this adds
a $isUnchangedBlock last one, so both become
$collectSegments(flavor, windows, points, $isUnchangedBlock).

Closes #9490

Test plan

MarkStateBlockConversion.test.ts states the invariant (converting blocks
keeps the ids of every character) over every source and target (paragraph,
heading, quote, code with and without highlighting, bullet, number and check
items, nested items as sources, no list as a target), with links, comments
on part, inside a link, overlapping or on all of the text, neighbouring
lists that the conversion merges with (including text that lines up and
shared ids), the caret or a range at several places, text typed in the same
update, and pasted blocks: 27,072 cases. Each also checks that the
conversion happened. It includes both reports and a comment on code text
turned into a paragraph; the lexical-y test round-trips the list item case
through a Yjs document, and the code-prism and code-shiki tests check that
NodeState (nested and flat) survives leaving a code block.

Before

New tests against main's sources:

$ pnpm exec vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateBlockConversion.test.ts packages/lexical-y/src/__tests__/unit/MarkStateBlockType.test.ts packages/lexical-code-prism/src/__tests__/unit/CodePrismLeaveCodeNodeState.test.ts packages/lexical-code-shiki/src/__tests__/unit/CodeShikiLeaveCodeNodeState.test.ts
 FAIL ... > reported cases > a paragraph turned into a list item keeps the comment on a link
-   "a:c0",
-   "b:c0",
-   "c:c0",
-   "d:c0",
+   "a:",
+   "b:",
+   "c:",
+   "d:",
 FAIL ... > MarkStateExtension with @lexical/y > a list item with a comment turned into a paragraph keeps the comment
Expected: "paragraph("alpha "[] "beta"["c1"])"
Received: "paragraph("alpha beta"[])"
 FAIL ... CodePrismLeaveCodeNodeState.test.ts > code highlight nodes turned back into text when they leave a code block keep their NodeState
AssertionError: expected [ [ true, 'note', '' ] ] to deeply equal [ [ true, 'note', 'kept' ] ]
...
 Test Files  4 failed (4)
      Tests  89 failed | 107 passed (196)

3,804 of the 27,072 matrix cases fail, for example:

"bullet -> paragraph, plain, marks part, alone, selection 17:
  before a: l: p: h: a:  : b:c1 e:c1 t:c1 a:c1  : g: a: m: m: a:  :
  after  a: l: p: h: a:  : b: e: t: a:  : g: a: m: m: a:  :"

After

$ pnpm exec vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateBlockConversion.test.ts packages/lexical-y/src/__tests__/unit/MarkStateBlockType.test.ts packages/lexical-code-prism/src/__tests__/unit/CodePrismLeaveCodeNodeState.test.ts packages/lexical-code-shiki/src/__tests__/unit/CodeShikiLeaveCodeNodeState.test.ts
 Test Files  4 passed (4)
      Tests  196 passed (196)

$ pnpm exec vitest run --project unit packages/lexical-y packages/lexical-mark/ packages/lexical-list packages/lexical-selection packages/lexical-code packages/lexical-table packages/lexical-rich-text packages/lexical-markdown
 Test Files  250 passed | 2 skipped (252)
      Tests  7251 passed | 16 expected fail | 3 skipped (7270)

$ MARK_STATE_PROPERTY_SEEDS=400 MARK_STATE_PROPERTY_STEPS=120 pnpm exec vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateProperty.test.ts
      Tests  1 passed (1)

$ pnpm run ci-check
Found 0 errors

Unit tests only (Node/jsdom); no browser or E2E run. Not covered (the
matrix skips them, with the reason): comment ids on part of a code
highlighting token, and marked text that a range conversion merges into a
highlighted code block, which re-tokenizing drops (#9491); and typing inside
a link in the same update that turns it into highlighted code, where the
highlighter puts the caret back at a wrong offset, so the typed text is not
before the caret as the inserted-text rule needs.

@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Oct 29, 2026 5:20pm UTC
lexical-playground Ready Ready Preview Oct 29, 2026 5:20pm 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 28, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9503

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 576b48d

* that `to` has for the keys `from` does not set (such as state that a node
* replacement initializes).
*/
function $copyNodeState(from: LexicalNode, to: LexicalNode): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would probably make this an @internal function in lexical since it's going to be used in every highlighter (with possible other use cases), and it could go through NodeState.getInternalState to read unknown/known directly instead of passing through JSON serialization.

…e][lexical-y] Bug Fix: Keep comment ids and NodeState when a block change moves or re-creates text

## Description

`MarkStateExtension` gives inserted text its comment ids with a root transform
that compares the blocks around the caret with the previous editor state. That
comparison only covered the sibling blocks of the caret's block and treated a
nested block (the items of a list) as one opaque unit. Text that a block
change moved in from outside it had no counterpart, so it was taken for text
typed into an existing node: it lost its own ids and got the ids shared by
its neighbours. The core keeps the NodeState in these conversions; with the
transform disabled, the ids survive.

- Turning a lone list item into a paragraph, quote or heading
  (`$setBlocksType`) removed every comment id from its text, with or without
  `@lexical/y`.
- Turning a paragraph into a list item that joins the list above it
  (`$insertList`) removed the comment from a link when the item above ended
  with the same text, or gave the moved text the comment of the item above
  (#9490).
- Converting a list item to a highlighted code block, which re-creates the
  text in new nodes, lost its ids too.
- Turning a highlighted code block into a paragraph lost them whenever the
  inserted-text rule could not run (with the code selected, or for pasted
  code): Prism and Shiki replace each code highlight node that leaves a code
  block with `$createTextNode(text)`, which drops its NodeState.

The comparison now:

- keeps the ids of a leaf that existed before the update and kept its text,
  wherever the update moved it, and of the text a moved TextNode kept at each
  end when the same update also typed into it (each run of inserted units
  between kept ones is an insertion, with the ids shared by its neighbours);
- compares a nested block that the update changed by its text, like the
  blocks around it (an unchanged one stays a single unit, so an edit next to
  a large nested list does not read it);
- also compares the following block when it lost its first children, as a
  list does when its first item is converted.

`@lexical/code-prism` and `@lexical/code-shiki` now copy the NodeState of a
code highlight node (flat states included) into the TextNode that replaces
it, keeping any state that a TextNode replacement sets, so any NodeState
survives leaving a code block, not only comment ids.

A node that code moves (rather than creates) into the middle of a marked run
now keeps exactly its own ids instead of taking the run's; `comments.md` says
so, and that block changes keep every id.

Independent of #9489 and #9496. It merges with #9489 without conflicts of its
own (that PR's `comments.md` conflict with main is unrelated). With #9496 it
has two one-line conflicts in `MarkStateExtension.ts`, at the two
`$collectSegments` calls: #9496 adds a `flavor` first argument and this adds
a `$isUnchangedBlock` last one, so both become
`$collectSegments(flavor, windows, points, $isUnchangedBlock)`.

Closes #9490

## Test plan

`MarkStateBlockConversion.test.ts` states the invariant (converting blocks
keeps the ids of every character) over every source and target (paragraph,
heading, quote, code with and without highlighting, bullet, number and check
items, nested items as sources, no list as a target), with links, comments
on part, inside a link, overlapping or on all of the text, neighbouring
lists that the conversion merges with (including text that lines up and
shared ids), the caret or a range at several places, text typed in the same
update, and pasted blocks: 27,072 cases. Each also checks that the
conversion happened. It includes both reports and a comment on code text
turned into a paragraph; the lexical-y test round-trips the list item case
through a Yjs document, and the code-prism and code-shiki tests check that
NodeState (nested and flat) survives leaving a code block.

### Before

New tests against `main`'s sources:

```
$ pnpm exec vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateBlockConversion.test.ts packages/lexical-y/src/__tests__/unit/MarkStateBlockType.test.ts packages/lexical-code-prism/src/__tests__/unit/CodePrismLeaveCodeNodeState.test.ts packages/lexical-code-shiki/src/__tests__/unit/CodeShikiLeaveCodeNodeState.test.ts
 FAIL ... > reported cases > a paragraph turned into a list item keeps the comment on a link
-   "a:c0",
-   "b:c0",
-   "c:c0",
-   "d:c0",
+   "a:",
+   "b:",
+   "c:",
+   "d:",
 FAIL ... > MarkStateExtension with @lexical/y > a list item with a comment turned into a paragraph keeps the comment
Expected: "paragraph("alpha "[] "beta"["c1"])"
Received: "paragraph("alpha beta"[])"
 FAIL ... CodePrismLeaveCodeNodeState.test.ts > code highlight nodes turned back into text when they leave a code block keep their NodeState
AssertionError: expected [ [ true, 'note', '' ] ] to deeply equal [ [ true, 'note', 'kept' ] ]
...
 Test Files  4 failed (4)
      Tests  89 failed | 107 passed (196)
```

3,804 of the 27,072 matrix cases fail, for example:

```
"bullet -> paragraph, plain, marks part, alone, selection 17:
  before a: l: p: h: a:  : b:c1 e:c1 t:c1 a:c1  : g: a: m: m: a:  :
  after  a: l: p: h: a:  : b: e: t: a:  : g: a: m: m: a:  :"
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateBlockConversion.test.ts packages/lexical-y/src/__tests__/unit/MarkStateBlockType.test.ts packages/lexical-code-prism/src/__tests__/unit/CodePrismLeaveCodeNodeState.test.ts packages/lexical-code-shiki/src/__tests__/unit/CodeShikiLeaveCodeNodeState.test.ts
 Test Files  4 passed (4)
      Tests  196 passed (196)

$ pnpm exec vitest run --project unit packages/lexical-y packages/lexical-mark/ packages/lexical-list packages/lexical-selection packages/lexical-code packages/lexical-table packages/lexical-rich-text packages/lexical-markdown
 Test Files  250 passed | 2 skipped (252)
      Tests  7251 passed | 16 expected fail | 3 skipped (7270)

$ MARK_STATE_PROPERTY_SEEDS=400 MARK_STATE_PROPERTY_STEPS=120 pnpm exec vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateProperty.test.ts
      Tests  1 passed (1)

$ pnpm run ci-check
Found 0 errors
```

Unit tests only (Node/jsdom); no browser or E2E run. Not covered (the
matrix skips them, with the reason): comment ids on part of a code
highlighting token, and marked text that a range conversion merges into a
highlighted code block, which re-tokenizing drops (#9491); and typing inside
a link in the same update that turns it into highlighted code, where the
highlighter puts the caret back at a wrong offset, so the typed text is not
before the caret as the inserted-text rule needs.
@zurfyx
zurfyx force-pushed the zurfyx/keep-node-state-on-block-convert branch from 80cf3c9 to 576b48d Compare October 29, 2026 17:18
@zurfyx
zurfyx added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 42a821d Oct 29, 2026
53 checks passed
zurfyx added a commit that referenced this pull request Oct 29, 2026
…maVersion

## Description

Rebasing this PR onto `main` brings in two tests that landed after it was
written and bind a plain `Y.Doc` without a `schemaVersion`, which this PR's
`YCollabExtension` and `@lexical/y/headless` config no longer accept:

- `TableOnOpen.test.ts` (#9501): the document is made with `createYDoc` from
  the seeded state, and the seed and the extension get `schemaVersion: 1`.
- `MarkStateBlockType.test.ts` (#9503): both documents are made with
  `createYDoc`, and the extension gets `schemaVersion: 1`.

## Test plan

### Before

```
$ npx tsc --noEmit -p tsconfig.json
packages/lexical-y/src/__tests__/unit/MarkStateBlockType.test.ts(54,44): error TS2741: Property '[createdByCreateYDoc]' is missing in type 'Doc' but required in type 'YDoc'.
packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts(58,9): error TS2345: Argument of type '{ extensions: ... }' is not assignable to parameter of type 'HeadlessYConfig'.
packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts(62,55): error TS2741: Property '[createdByCreateYDoc]' is missing in type 'Doc' but required in type 'YDoc'.
```

### After

```
$ pnpm run ci-check
Found 0 errors
$ npx vitest run --project unit
 Test Files  563 passed | 3 skipped (566)
      Tests  14641 passed | 46 expected fail | 6 skipped (14693)
```
etrepum pushed a commit to etrepum/lexical that referenced this pull request Oct 29, 2026
…ed code block's selection as a caret again, so comment ids are kept

## Description

The previous fix kept both ends of a range selection across a highlighter retokenize. Upstream's meta#9503, merged in through the PR head, has `MarkStateExtension` carry comment ids onto the new tokens only while the selection is collapsed, so with the range kept, converting selected commented text to a code block dropped its comments. The highlighters restore the selection as a caret at the anchor again, as before, still by text offset, and the range tests are dropped.

## Test plan

### Before

```
$ ./node_modules/.bin/vitest run --project unit packages/lexical-mark/src/__tests__/unit/MarkStateBlockConversion.test.ts
      Tests  19 failed | 170 passed (189)
```

### After

```
$ ./node_modules/.bin/vitest run --project unit packages/lexical-mark packages/lexical-code-shiki packages/lexical-code-prism packages/lexical-code packages/lexical-code-core packages/lexical-playground/__tests__/unit
      Tests  1821 passed | 1 skipped (1822)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zurfyx added a commit that referenced this pull request Nov 2, 2026
…become text

## Description

`registerLexicalTextEntity` turns an entity back into plain text when it stops matching (typing right before a hashtag, deleting its `#`, joining blocks), with `$replaceWithSimpleText`. That built the new `TextNode` from the entity's text, format, style and detail, but not its NodeState, so anything an app keeps on the characters as NodeState (comment ids, suggestion marks, block ids) was dropped (#9703). The same happened in the other direction: plain text with NodeState that becomes an entity gave the entity none.

What carries, and why:

- **Demotion (entity to text): all of the entity's NodeState**, nested and flat, known and not yet parsed (read from JSON), the same value objects (ids compared by identity stay identical). NodeState is the ad-hoc data an app keeps on text; what is inherent to the entity type is its class (its fields) and its mode, which stay with the type, as before. This is what `splitText` already does for the pieces it cuts, and what the code highlighters do since #9503 when a code token becomes text. A key that the plain text type declares with a different `StateConfig` is parsed with the text type's config. Format, style and detail carry as before.
- **Promotion (text to entity): the text's NodeState too**, for every key `createNode` did not set (its state wins), with one exception: text that the entity's own transform splits off its end in the same update (text typed or pasted right after a hashtag). `splitText` gives that piece a copy of the entity's NodeState, but those characters were just inserted, so it is not theirs; an entity made from it keeps the old behaviour (MarkStateExtension relies on telling inserted text from marked text this way). An entity turned into text and back in one update gets its own NodeState back.
- **Token and segmented entities** that text follows are turned into text so that they merge with it and are parsed again. With their NodeState kept they do not merge with text that has other NodeState, and the transforms would make the entity again and turn it back forever (on `main` this already looped whenever that text had NodeState, or another format). So, as in #9686, when the same entity (same parent, node before it and text) comes back to be turned into text a third time in one update (or updates batched into one commit), it is kept, with its NodeState. It takes the text its match extends into, as an entity in normal mode does, only when that text would merge with it (the same format, style, detail and NodeState; the selection stays where it was in that text); text with other NodeState or format stays apart, so it neither takes the entity's NodeState nor makes the entity drop its own. Until the third time the transforms run as before. The count belongs to the pending editor state, so an update that fails does not count toward the next.
- The text split off the end of an entity is tracked per editor, for every registration, so an entity of another registration made from it (a keyword typed after a hashtag) does not take the copy either.

Two text nodes merge only when their NodeState is equal, so text that keeps an entity's NodeState stays its own node next to text with other NodeState, as text in another format does. That is the point (the characters keep their comment), and it is the only structural difference: text with no NodeState merges as before.

Callers in this repo, all through `registerLexicalTextEntity`: `@lexical/hashtag` (`registerLexicalHashtag`, `HashtagExtension`, `LexicalHashtagPlugin`), `@lexical/react` `useLexicalTextEntity`, and the playground's `KeywordsExtension`. Mentions (typeahead) and `@lexical/link` autolink have their own transforms and do not share this path.

Fixes #9703

## Test plan

`.changeset/lexical-text-entity-node-state.md` (`@lexical/text`, patch, `[fix]`).

`registerLexicalTextEntityNodeState.test.ts` (new, 22 tests): #9703's repro; deleting a hashtag's `#`; merging into neighbours with the same NodeState and, without NodeState, merging as before; promotion with NodeState, `createNode`'s state winning, and text inserted after a hashtag not taking the copy; a reference-typed value carried as the same object; NodeState read from JSON and not parsed yet; NodeState declared in the entity's `$config` (nested and flat) and exported nested on the text; a key the text type declares with another config; a hashtag inside a MarkNode; undo and redo; and every path that turns an entity into text: the text transform with no match, a token entity that text follows (same NodeState: it takes the text; other NodeState: kept, the text stays apart; the selection after pasted text; text with its own NodeState, which loops on `main`), the next entity that stops matching, adjacent entities, an entity followed by another, and a join across text in another format.

`registerLexicalTextEntityNodeStateProperty.test.ts` (new): random layouts of entities (hashtags, and token mentions) and text in several formats, with NodeState on both and MarkNodes around some, and random edits (typing, backspace and delete, joining blocks, removing ranges across nodes). Each entity has a letter no other text has; after every edit that turns an entity into text, every character of it that is still there has the entity's NodeState and MarkNode (or sits inside another entity that grew over it, which takes characters with its own NodeState, as before). 200 seeds per matcher (`TEXT_ENTITY_NODE_STATE_SEEDS`).

Before (main):

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeState.test.ts packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 × #9703: a hashtag that stops matching keeps its NodeState when it becomes text (typing before it)
 × deleting the # of a hashtag keeps its NodeState (reverse transform, no match)
 × a hashtag that becomes text merges into neighbors with the same NodeState
 × plain text with NodeState that becomes a hashtag keeps it
 × state that createNode gives the entity wins over the text it replaces
 × a reference-typed state value is carried as the same object
 × NodeState read from JSON (not parsed yet) is carried and exported
 × NodeState declared in the entity $config (nested and flat) is carried to the text
 × a key the plain text type declares with another config is parsed with that config
 × a hashtag inside a MarkNode stays inside it when it becomes text
 × undo and redo of a demotion restore the entity and the text with their NodeState
 × the text transform: the entity before it stops matching with its text (prevMatch null)
 × with the same NodeState as the text: it keeps it and takes the text
 × with other NodeState than the text: kept with its NodeState, the text stays apart
 × typed text after it: the selection stays after the typed text
 × with other NodeState than text that its match extends into, which keeps it apart: kept, each keeps its own
 × the text transform: the entity after it stops matching (nextMatch null)
 × the reverse transform: adjacent entities both become text
 × the reverse transform: an entity followed by an entity
 × hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 × mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
      Tests  21 failed | 3 passed (24)
```

After:

```
$ pnpm exec vitest run --project unit packages/lexical-text packages/lexical-hashtag packages/lexical-mark packages/lexical-history
      Tests  1375 passed (1375)
$ TEXT_ENTITY_NODE_STATE_SEEDS=20000 pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 ✓ hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 ✓ mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
```

The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts` (JSON, selection, mutations and keys after random sessions, against the helper before #9483, which has no NodeState in its layouts), and `MarkStateExtension`'s hashtag tests (text inserted after a marked hashtag is not marked).

A differential against `main` over random editing sessions (typing, backspace and delete, joins across paragraphs, headings, quotes, list items and table cells, ranges, formatting, paste with NodeState, undo and redo, IME composition) with hashtags, a keyword matcher, a token and a segmented matcher, autolink and MarkNodes: the NodeState of an entity's characters is kept in tens of thousands of steps where `main` drops it; no text is lost; token and segmented entities that loop on `main` settle. Where NodeState now keeps text apart, the entities can differ from `main`, as they do next to text in another format; a keyword-style matcher can then reach #9637's loop in a layout `main` merged (a few per 10,000 sessions; hashtags never do), which #9637's fix settles.
zurfyx added a commit that referenced this pull request Nov 2, 2026
…hen they become text

## Description

`registerLexicalTextEntity` turns an entity back into plain text when it stops matching (typing right before a hashtag, deleting its `#`, joining blocks), with `$replaceWithSimpleText`. That built the new `TextNode` from the entity's text, format, style and detail, but not its NodeState, so anything an app keeps on the characters as NodeState (comment ids, suggestion marks, block ids) was dropped (#9703). The same happened in the other direction: plain text with NodeState that becomes an entity gave the entity none.

What carries, and why:

- **Demotion (entity to text): all of the entity's NodeState**, nested and flat, known and not yet parsed (read from JSON), the same value objects (ids compared by identity stay identical). NodeState is the ad-hoc data an app keeps on text; what is inherent to the entity type is its class (its fields) and its mode, which stay with the type, as before. This is what `splitText` already does for the pieces it cuts, and what the code highlighters do since #9503 when a code token becomes text. A key that the plain text type declares with a different `StateConfig` is parsed with the text type's config. Format, style and detail carry as before.
- **Promotion (text to entity): the text's NodeState too**, for every key `createNode` did not set (its state wins), with one exception: text that the entity's own transform splits off its end in the same update (text typed or pasted right after a hashtag). `splitText` gives that piece a copy of the entity's NodeState, but those characters were just inserted, so it is not theirs; an entity made from it keeps the old behaviour (MarkStateExtension relies on telling inserted text from marked text this way). An entity turned into text and back in one update gets its own NodeState back.
- **Token and segmented entities** that text follows are turned into text so that they merge with it and are parsed again. With their NodeState kept they do not merge with text that has other NodeState, and the transforms would make the entity again and turn it back forever (on `main` this already looped whenever that text had NodeState, or another format). So, as in #9686, when the same entity (same parent, text, and place: the first node before it that is no text entity, and the size of the entities between) comes back to be turned into text a third time in one update (or updates batched into one commit), it is kept, with its NodeState. It takes the text its match extends into, as an entity in normal mode does, only when that text would merge with it (the same format, style and detail, and NodeState equal by the rule text nodes merge by: `StateConfig.isEqual`, so two distinct value objects are two values; the selection stays where it was in that text); text with other NodeState or format stays apart, so it neither takes the entity's NodeState nor makes the entity drop its own. Until the third time the transforms run as before. The count belongs to the pending editor state, so an update that fails does not count toward the next.
- The text split off the end of an entity is tracked per editor, for every registration, so an entity of another registration made from it (a keyword typed after a hashtag) does not take the copy either.
- **Core: typing inside a segmented node** (outside composition) replaces it with a plain `TextNode` built from its text, format and style, the same way `$replaceWithSimpleText` did, and dropped its NodeState the same way. That node now keeps it (`$carryNodeState`, internal to `lexical`, with the same rules as above). Segmented entities that this change keeps or makes reach it more often, so it is fixed here.

Two text nodes merge only when their NodeState is equal, so text that keeps an entity's NodeState stays its own node next to text with other NodeState, as text in another format does. That is the point (the characters keep their comment), and it is the only structural difference: text with no NodeState merges as before.

Callers in this repo, all through `registerLexicalTextEntity`: `@lexical/hashtag` (`registerLexicalHashtag`, `HashtagExtension`, `LexicalHashtagPlugin`), `@lexical/react` `useLexicalTextEntity`, and the playground's `KeywordsExtension`. Mentions (typeahead) and `@lexical/link` autolink have their own transforms and do not share this path.

Fixes #9703

## Test plan

`.changeset/lexical-text-entity-node-state.md` (`lexical` and `@lexical/text`, patch, `[fix]`).

`LexicalSelection.test.ts`: typing inside a segmented node keeps its NodeState (fails on `main`).

`registerLexicalTextEntityNodeState.test.ts` (new, 23 tests): #9703's repro; deleting a hashtag's `#`; merging into neighbours with the same NodeState and, without NodeState, merging as before; promotion with NodeState, `createNode`'s state winning, and text inserted after a hashtag not taking the copy; a reference-typed value carried as the same object; NodeState read from JSON and not parsed yet; NodeState declared in the entity's `$config` (nested and flat) and exported nested on the text; a key the text type declares with another config; a hashtag inside a MarkNode; undo and redo; and every path that turns an entity into text: the text transform with no match, a token entity that text follows (same NodeState: it takes the text; other NodeState: kept, the text stays apart; the selection after pasted text; text with its own NodeState, which loops on `main`; a value object distinct from the text's, equal as JSON), the next entity that stops matching, adjacent entities, an entity followed by another, and a join across text in another format.

`registerLexicalTextEntityNodeStateProperty.test.ts` (new): random layouts of entities (hashtags, and token mentions) and text in several formats, with NodeState on both and MarkNodes around some, and random edits (typing, backspace and delete, joining blocks, removing ranges across nodes). Each entity has a letter no other text has; after every edit that turns an entity into text, every character of it that is still there has the entity's NodeState and MarkNode (or sits inside another entity that grew over it, which takes characters with its own NodeState, as before). 200 seeds per matcher (`TEXT_ENTITY_NODE_STATE_SEEDS`).

Before (main):

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeState.test.ts packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 × #9703: a hashtag that stops matching keeps its NodeState when it becomes text (typing before it)
 × deleting the # of a hashtag keeps its NodeState (reverse transform, no match)
 × a hashtag that becomes text merges into neighbors with the same NodeState
 × plain text with NodeState that becomes a hashtag keeps it
 × state that createNode gives the entity wins over the text it replaces
 × a reference-typed state value is carried as the same object
 × NodeState read from JSON (not parsed yet) is carried and exported
 × NodeState declared in the entity $config (nested and flat) is carried to the text
 × a key the plain text type declares with another config is parsed with that config
 × a hashtag inside a MarkNode stays inside it when it becomes text
 × undo and redo of a demotion restore the entity and the text with their NodeState
 × the text transform: the entity before it stops matching with its text (prevMatch null)
 × with the same NodeState as the text: it keeps it and takes the text
 × with other NodeState than the text: kept with its NodeState, the text stays apart
 × typed text after it: the selection stays after the typed text
 × with other NodeState than text that its match extends into, which keeps it apart: kept, each keeps its own
 × with a value object distinct from the text after it, equal as JSON: each keeps its own
 × the text transform: the entity after it stops matching (nextMatch null)
 × the reverse transform: adjacent entities both become text
 × the reverse transform: an entity followed by an entity
 × hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 × mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
      Tests  22 failed | 3 passed (25)
```

After:

```
$ pnpm exec vitest run --project unit packages/lexical-text packages/lexical-hashtag packages/lexical-mark packages/lexical-history
      Tests  1376 passed (1376)
$ pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
      Tests  101 passed (101)
$ TEXT_ENTITY_NODE_STATE_SEEDS=20000 pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 ✓ hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 ✓ mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
```

The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts` (JSON, selection, mutations and keys after random sessions, against the helper before #9483, which has no NodeState in its layouts), and `MarkStateExtension`'s hashtag tests (text inserted after a marked hashtag is not marked).

A differential against `main` over random editing sessions (typing, backspace and delete, joins across paragraphs, headings, quotes, list items and table cells, ranges, formatting, paste with NodeState, undo and redo, IME composition) with hashtags, a keyword matcher, a token and a segmented matcher, autolink and MarkNodes: the NodeState of an entity's characters is kept in tens of thousands of steps where `main` drops it; no text is lost; token and segmented entities that loop on `main` settle. Where NodeState now keeps text apart, the entities can differ from `main`, as they do next to text in another format; a keyword-style matcher can then reach #9637's loop in a layout `main` merged (a few per 10,000 sessions; hashtags never do), which #9637's fix settles.
zurfyx added a commit that referenced this pull request Nov 2, 2026
…hen they become text

## Description

`registerLexicalTextEntity` turns an entity back into plain text when it stops matching (typing right before a hashtag, deleting its `#`, joining blocks), with `$replaceWithSimpleText`. That built the new `TextNode` from the entity's text, format, style and detail, but not its NodeState, so anything an app keeps on the characters as NodeState (comment ids, suggestion marks, block ids) was dropped (#9703). The same happened in the other direction: plain text with NodeState that becomes an entity gave the entity none.

What carries, and why:

- **Demotion (entity to text): all of the entity's NodeState**, nested and flat, known and not yet parsed (read from JSON), the same value objects (ids compared by identity stay identical). NodeState is the ad-hoc data an app keeps on text; what is inherent to the entity type is its class (its fields) and its mode, which stay with the type, as before. This is what `splitText` already does for the pieces it cuts, and what the code highlighters do since #9503 when a code token becomes text. A key that the plain text type declares with a different `StateConfig` is parsed with the text type's config. Format, style and detail carry as before.
- **Promotion (text to entity): the text's NodeState too**, for every key `createNode` did not set (its state wins), with one exception: text that the entity's own transform splits off its end in the same update (text typed or pasted right after a hashtag). `splitText` gives that piece a copy of the entity's NodeState, but those characters were just inserted, so it is not theirs; an entity made from it keeps the old behaviour (MarkStateExtension relies on telling inserted text from marked text this way). An entity turned into text and back in one update gets its own NodeState back.
- **Token and segmented entities** that text follows are turned into text so that they merge with it and are parsed again. With their NodeState kept they do not merge with text that has other NodeState, and the transforms would make the entity again and turn it back forever (on `main` this already looped whenever that text had NodeState, or another format). So, as in #9686, when the same entity (same parent, text, and place: the first node before it that is no text entity, and the size of the entities between) comes back to be turned into text a third time in one update (or updates batched into one commit), it is kept, with its NodeState. It takes the text its match extends into, as an entity in normal mode does, only when that text would merge with it (the same format, style and detail, and NodeState equal by the rule text nodes merge by: `StateConfig.isEqual`, so two distinct value objects are two values; the selection stays where it was in that text); text with other NodeState or format stays apart, so it neither takes the entity's NodeState nor makes the entity drop its own. Until the third time the transforms run as before. The count belongs to the pending editor state, so an update that fails does not count toward the next.
- The text split off the end of an entity is tracked per editor, for every registration, so an entity of another registration made from it (a keyword typed after a hashtag) does not take the copy either.
- **Core: editing inside a segmented node** replaces it with a plain `TextNode` built from its text, format and style, the same way `$replaceWithSimpleText` did, and dropped its NodeState the same way: typing inside it (outside composition, `LexicalSelection`) and removing a range that starts or ends inside it (`LexicalCaretUtils`). Both now keep it (`$carryNodeState`, internal to `lexical`, with the same rules as above; keys the new node already holds, such as state a registered `TextNode` replacement sets, keep their value). Segmented entities that this change keeps or makes reach these paths more often, so they are fixed here.

Two text nodes merge only when their NodeState is equal, so text that keeps an entity's NodeState stays its own node next to text with other NodeState, as text in another format does. That is the point (the characters keep their comment), and it is the only structural difference: text with no NodeState merges as before.

Callers in this repo, all through `registerLexicalTextEntity`: `@lexical/hashtag` (`registerLexicalHashtag`, `HashtagExtension`, `LexicalHashtagPlugin`), `@lexical/react` `useLexicalTextEntity`, and the playground's `KeywordsExtension`. Mentions (typeahead) and `@lexical/link` autolink have their own transforms and do not share this path.

Fixes #9703

## Test plan

`.changeset/lexical-text-entity-node-state.md` (`lexical` and `@lexical/text`, patch, `[fix]`).

`LexicalSelection.test.ts` (3 tests, each fails on `main`): typing inside a segmented node keeps its NodeState; a registered `TextNode` replacement keeps the NodeState it sets and takes the rest; removing a range from inside a segmented node to the next block keeps the NodeState of what is left.

`registerLexicalTextEntityNodeState.test.ts` (new, 23 tests): #9703's repro; deleting a hashtag's `#`; merging into neighbours with the same NodeState and, without NodeState, merging as before; promotion with NodeState, `createNode`'s state winning, and text inserted after a hashtag not taking the copy; a reference-typed value carried as the same object; NodeState read from JSON and not parsed yet; NodeState declared in the entity's `$config` (nested and flat) and exported nested on the text; a key the text type declares with another config; a hashtag inside a MarkNode; undo and redo; and every path that turns an entity into text: the text transform with no match, a token entity that text follows (same NodeState: it takes the text; other NodeState: kept, the text stays apart; the selection after pasted text; text with its own NodeState, which loops on `main`; a value object distinct from the text's, equal as JSON), the next entity that stops matching, adjacent entities, an entity followed by another, and a join across text in another format.

`registerLexicalTextEntityNodeStateProperty.test.ts` (new): random layouts of entities (hashtags, and token mentions) and text in several formats, with NodeState on both and MarkNodes around some, and random edits (typing, backspace and delete, joining blocks, removing ranges across nodes). Each entity has a letter no other text has; after every edit that turns an entity into text, every character of it that is still there has the entity's NodeState and MarkNode (or sits inside another entity that grew over it, which takes characters with its own NodeState, as before). 200 seeds per matcher (`TEXT_ENTITY_NODE_STATE_SEEDS`).

Before (main):

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeState.test.ts packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 × #9703: a hashtag that stops matching keeps its NodeState when it becomes text (typing before it)
 × deleting the # of a hashtag keeps its NodeState (reverse transform, no match)
 × a hashtag that becomes text merges into neighbors with the same NodeState
 × plain text with NodeState that becomes a hashtag keeps it
 × state that createNode gives the entity wins over the text it replaces
 × a reference-typed state value is carried as the same object
 × NodeState read from JSON (not parsed yet) is carried and exported
 × NodeState declared in the entity $config (nested and flat) is carried to the text
 × a key the plain text type declares with another config is parsed with that config
 × a hashtag inside a MarkNode stays inside it when it becomes text
 × undo and redo of a demotion restore the entity and the text with their NodeState
 × the text transform: the entity before it stops matching with its text (prevMatch null)
 × with the same NodeState as the text: it keeps it and takes the text
 × with other NodeState than the text: kept with its NodeState, the text stays apart
 × typed text after it: the selection stays after the typed text
 × with other NodeState than text that its match extends into, which keeps it apart: kept, each keeps its own
 × with a value object distinct from the text after it, equal as JSON: each keeps its own
 × the text transform: the entity after it stops matching (nextMatch null)
 × the reverse transform: adjacent entities both become text
 × the reverse transform: an entity followed by an entity
 × hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 × mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
      Tests  22 failed | 3 passed (25)
```

After:

```
$ pnpm exec vitest run --project unit packages/lexical-text packages/lexical-hashtag packages/lexical-mark packages/lexical-history
      Tests  1376 passed (1376)
$ pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
      Tests  103 passed (103)
$ TEXT_ENTITY_NODE_STATE_SEEDS=20000 pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 ✓ hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 ✓ mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
```

The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts` (JSON, selection, mutations and keys after random sessions, against the helper before #9483, which has no NodeState in its layouts), and `MarkStateExtension`'s hashtag tests (text inserted after a marked hashtag is not marked).

A differential against `main` over random editing sessions (typing, backspace and delete, joins across paragraphs, headings, quotes, list items and table cells, ranges, formatting, paste with NodeState, undo and redo, IME composition) with hashtags, a keyword matcher, a token and a segmented matcher, autolink and MarkNodes: the NodeState of an entity's characters is kept in tens of thousands of steps where `main` drops it; no text is lost; token and segmented entities that loop on `main` settle. Where NodeState now keeps text apart, the entities can differ from `main`, as they do next to text in another format; a keyword-style matcher can then reach #9637's loop in a layout `main` merged (a few per 10,000 sessions; hashtags never do), which #9637's fix settles.
zurfyx added a commit that referenced this pull request Nov 2, 2026
…hen they become text

## Description

`registerLexicalTextEntity` turns an entity back into plain text when it stops matching (typing right before a hashtag, deleting its `#`, joining blocks), with `$replaceWithSimpleText`. That built the new `TextNode` from the entity's text, format, style and detail, but not its NodeState, so anything an app keeps on the characters as NodeState (comment ids, suggestion marks, block ids) was dropped (#9703). The same happened in the other direction: plain text with NodeState that becomes an entity gave the entity none.

What carries, and why:

- **Demotion (entity to text): all of the entity's NodeState**, nested and flat, known and not yet parsed (read from JSON), the same value objects (ids compared by identity stay identical). NodeState is the ad-hoc data an app keeps on text; what is inherent to the entity type is its class (its fields) and its mode, which stay with the type, as before. This is what `splitText` already does for the pieces it cuts, and what the code highlighters do since #9503 when a code token becomes text. A key that the plain text type declares with a different `StateConfig` is parsed with the text type's config. Format, style and detail carry as before.
- **Promotion (text to entity): the text's NodeState too**, for every key `createNode` did not set (its state wins), with one exception: text that the entity's own transform splits off its end in the same update (text typed or pasted right after a hashtag). `splitText` gives that piece a copy of the entity's NodeState, but those characters were just inserted, so it is not theirs; an entity made from it keeps the old behaviour (MarkStateExtension relies on telling inserted text from marked text this way). An entity turned into text and back in one update gets its own NodeState back.
- **Token and segmented entities** that text follows are turned into text so that they merge with it and are parsed again. With their NodeState kept they do not merge with text that has other NodeState, and the transforms would make the entity again and turn it back forever (on `main` this already looped whenever that text had NodeState, or another format). So, as in #9686, when the same entity (same parent, text, and place: the first node before it that is no text entity, and the size of the entities between) comes back to be turned into text a third time in one update (or updates batched into one commit), it is kept, with its NodeState. It takes the text its match extends into, as an entity in normal mode does, only when that text would merge with it (the same format, style and detail, and NodeState equal by the rule text nodes merge by: `StateConfig.isEqual`, so two distinct value objects are two values; the selection stays where it was in that text); text with other NodeState or format stays apart, so it neither takes the entity's NodeState nor makes the entity drop its own. Until the third time the transforms run as before. The count belongs to the pending editor state, so an update that fails does not count toward the next.
- The text split off the end of an entity is tracked per editor, for every registration, so an entity of another registration made from it (a keyword typed after a hashtag) does not take the copy either.
- **Core: editing inside a segmented node** replaces it with a plain `TextNode` built from its text, format and style, the same way `$replaceWithSimpleText` did, and dropped its NodeState the same way (and its detail, such as `unmergeable`, which `$replaceWithSimpleText` kept): typing inside it (outside composition, `LexicalSelection`) and removing a range that starts or ends inside it (`LexicalCaretUtils`). Both now keep them (detail, and NodeState with `$carryNodeState`, internal to `lexical`, with the same rules as above; keys the new node already holds, such as state a registered `TextNode` replacement sets, keep their value). Segmented entities that this change keeps or makes reach these paths more often, so they are fixed here.

Two text nodes merge only when their NodeState is equal, so text that keeps an entity's NodeState stays its own node next to text with other NodeState, as text in another format does. That is the point (the characters keep their comment), and it is the only structural difference: text with no NodeState merges as before.

Callers in this repo, all through `registerLexicalTextEntity`: `@lexical/hashtag` (`registerLexicalHashtag`, `HashtagExtension`, `LexicalHashtagPlugin`), `@lexical/react` `useLexicalTextEntity`, and the playground's `KeywordsExtension`. Mentions (typeahead) and `@lexical/link` autolink have their own transforms and do not share this path.

Fixes #9703

## Test plan

`.changeset/lexical-text-entity-node-state.md` (`lexical` and `@lexical/text`, patch, `[fix]`).

`LexicalSelection.test.ts` (3 tests, each fails on `main`): typing inside a segmented node keeps its NodeState and detail; a registered `TextNode` replacement keeps the NodeState it sets and takes the rest; removing a range from inside a segmented node to the next block keeps the NodeState and detail of what is left.

`registerLexicalTextEntityNodeState.test.ts` (new, 23 tests): #9703's repro; deleting a hashtag's `#`; merging into neighbours with the same NodeState and, without NodeState, merging as before; promotion with NodeState, `createNode`'s state winning, and text inserted after a hashtag not taking the copy; a reference-typed value carried as the same object; NodeState read from JSON and not parsed yet; NodeState declared in the entity's `$config` (nested and flat) and exported nested on the text; a key the text type declares with another config; a hashtag inside a MarkNode; undo and redo; and every path that turns an entity into text: the text transform with no match, a token entity that text follows (same NodeState: it takes the text; other NodeState: kept, the text stays apart; the selection after pasted text; text with its own NodeState, which loops on `main`; a value object distinct from the text's, equal as JSON), the next entity that stops matching, adjacent entities, an entity followed by another, and a join across text in another format.

`registerLexicalTextEntityNodeStateProperty.test.ts` (new): random layouts of entities (hashtags, and token mentions) and text in several formats, with NodeState on both and MarkNodes around some, and random edits (typing, backspace and delete, joining blocks, removing ranges across nodes). Each entity has a letter no other text has; after every edit that turns an entity into text, every character of it that is still there has the entity's NodeState and MarkNode (or sits inside another entity that grew over it, which takes characters with its own NodeState, as before). 200 seeds per matcher (`TEXT_ENTITY_NODE_STATE_SEEDS`).

Before (main):

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeState.test.ts packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 × #9703: a hashtag that stops matching keeps its NodeState when it becomes text (typing before it)
 × deleting the # of a hashtag keeps its NodeState (reverse transform, no match)
 × a hashtag that becomes text merges into neighbors with the same NodeState
 × plain text with NodeState that becomes a hashtag keeps it
 × state that createNode gives the entity wins over the text it replaces
 × a reference-typed state value is carried as the same object
 × NodeState read from JSON (not parsed yet) is carried and exported
 × NodeState declared in the entity $config (nested and flat) is carried to the text
 × a key the plain text type declares with another config is parsed with that config
 × a hashtag inside a MarkNode stays inside it when it becomes text
 × undo and redo of a demotion restore the entity and the text with their NodeState
 × the text transform: the entity before it stops matching with its text (prevMatch null)
 × with the same NodeState as the text: it keeps it and takes the text
 × with other NodeState than the text: kept with its NodeState, the text stays apart
 × typed text after it: the selection stays after the typed text
 × with other NodeState than text that its match extends into, which keeps it apart: kept, each keeps its own
 × with a value object distinct from the text after it, equal as JSON: each keeps its own
 × the text transform: the entity after it stops matching (nextMatch null)
 × the reverse transform: adjacent entities both become text
 × the reverse transform: an entity followed by an entity
 × hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 × mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
      Tests  22 failed | 3 passed (25)
```

After:

```
$ pnpm exec vitest run --project unit packages/lexical-text packages/lexical-hashtag packages/lexical-mark packages/lexical-history
      Tests  1376 passed (1376)
$ pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
      Tests  103 passed (103)
$ TEXT_ENTITY_NODE_STATE_SEEDS=20000 pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 ✓ hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 ✓ mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
```

The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts` (JSON, selection, mutations and keys after random sessions, against the helper before #9483, which has no NodeState in its layouts), and `MarkStateExtension`'s hashtag tests (text inserted after a marked hashtag is not marked).

A differential against `main` over random editing sessions (typing, backspace and delete, joins across paragraphs, headings, quotes, list items and table cells, ranges, formatting, paste with NodeState, undo and redo, IME composition) with hashtags, a keyword matcher, a token and a segmented matcher, autolink and MarkNodes: the NodeState of an entity's characters is kept in tens of thousands of steps where `main` drops it; no text is lost; token and segmented entities that loop on `main` settle. Where NodeState now keeps text apart, the entities can differ from `main`, as they do next to text in another format; a keyword-style matcher can then reach #9637's loop in a layout `main` merged (a few per 10,000 sessions; hashtags never do), which #9637's fix settles.
zurfyx added a commit that referenced this pull request Nov 2, 2026
…ir NodeState when they become text

## Description

`registerLexicalTextEntity` turns an entity back into plain text when it stops matching (typing right before a hashtag, deleting its `#`, joining blocks), with `$replaceWithSimpleText`. That built the new `TextNode` from the entity's text, format, style and detail, but not its NodeState, so anything an app keeps on the characters as NodeState (comment ids, suggestion marks, block ids) was dropped (#9703). The same happened in the other direction: plain text with NodeState that becomes an entity gave the entity none.

What carries, and why:

- **Demotion (entity to text): all of the entity's NodeState**, nested and flat, known and not yet parsed (read from JSON), the same value objects (ids compared by identity stay identical). NodeState is the ad-hoc data an app keeps on text; what is inherent to the entity type is its class (its fields) and its mode, which stay with the type, as before. This is what `splitText` already does for the pieces it cuts, and what the code highlighters do since #9503 when a code token becomes text. A key that the plain text type declares with a different `StateConfig` is parsed with the text type's config. Format, style and detail carry as before.
- **Promotion (text to entity): the text's NodeState too**, for every key `createNode` did not set (its state wins), with one exception: text that the entity's own transform splits off its end in the same update (text typed or pasted right after a hashtag). `splitText` gives that piece a copy of the entity's NodeState, but those characters were just inserted, so it is not theirs; an entity made from it keeps the old behaviour (MarkStateExtension relies on telling inserted text from marked text this way). An entity turned into text and back in one update gets its own NodeState back.
- **Token and segmented entities** that text follows are turned into text so that they merge with it and are parsed again. With their NodeState kept they do not merge with text that has other NodeState, and the transforms would make the entity again and turn it back forever (on `main` this already looped whenever that text had NodeState, or another format). So, as in #9686, when the same entity (same parent, text, and place: the first node before it that is no text entity, and the size of the entities between) comes back to be turned into text a third time in one update (or updates batched into one commit), it is kept, with its NodeState. It takes the text its match extends into, as an entity in normal mode does, only when that text would merge with it (the same format, style and detail, and NodeState equal by the rule text nodes merge by: `StateConfig.isEqual`, so two distinct value objects are two values; the selection stays where it was in that text); text with other NodeState or format stays apart, so it neither takes the entity's NodeState nor makes the entity drop its own. Until the third time the transforms run as before. The count belongs to the pending editor state, so an update that fails does not count toward the next.
- The text split off the end of an entity is tracked per editor, for every registration, so an entity of another registration made from it (a keyword typed after a hashtag) does not take the copy either.
- **`MarkStateExtension`**: text inserted at the edge of a comment lands in the commented node, and an entity transform that makes a hashtag of it (pasting ` #tag` right after a comment) splits it off with a copy of that node's comment ids, which the hashtag now keeps. `MarkStateExtension` already treats such a copy as not the inserted text's own when the piece is plain text (`$isSplitOffEntity`); it now does the same for a text entity made in the update whose ids are the same value as the ids of a node that existed (a copy, never ids of its own: pasted or inserted content brings its own value), so the hashtag is not added to the comment, as on `main` and with `MarkNode`.
- **Core: editing inside a segmented node** replaces it with a plain `TextNode` built from its text, format and style, the same way `$replaceWithSimpleText` did, and dropped its NodeState the same way (and its detail, such as `unmergeable`, which `$replaceWithSimpleText` kept): typing inside it (outside composition, `LexicalSelection`) and removing a range that starts or ends inside it (`LexicalCaretUtils`). Both now keep them (detail, and NodeState with `$carryNodeState`, internal to `lexical`, with the same rules as above; keys the new node already holds, such as state a registered `TextNode` replacement sets, keep their value). Segmented entities that this change keeps or makes reach these paths more often, so they are fixed here.

Two text nodes merge only when their NodeState is equal, so text that keeps an entity's NodeState stays its own node next to text with other NodeState, as text in another format does. That is the point (the characters keep their comment), and it is the only structural difference: text with no NodeState merges as before.

Callers in this repo, all through `registerLexicalTextEntity`: `@lexical/hashtag` (`registerLexicalHashtag`, `HashtagExtension`, `LexicalHashtagPlugin`), `@lexical/react` `useLexicalTextEntity`, and the playground's `KeywordsExtension`. Mentions (typeahead) and `@lexical/link` autolink have their own transforms and do not share this path.

Fixes #9703

## Test plan

`.changeset/lexical-text-entity-node-state.md` (`lexical` and `@lexical/text`, patch, `[fix]`).

`MarkStateExtension.test.ts`: a hashtag pasted or inserted at the edge of a comment (after it, and before it) is not added to it, with `MarkStateExtension` and with `MarkNode`.

`LexicalSelection.test.ts` (3 tests, each fails on `main`): typing inside a segmented node keeps its NodeState and detail; a registered `TextNode` replacement keeps the NodeState it sets and takes the rest; removing a range from inside a segmented node to the next block keeps the NodeState and detail of what is left.

`registerLexicalTextEntityNodeState.test.ts` (new, 23 tests): #9703's repro; deleting a hashtag's `#`; merging into neighbours with the same NodeState and, without NodeState, merging as before; promotion with NodeState, `createNode`'s state winning, and text inserted after a hashtag not taking the copy; a reference-typed value carried as the same object; NodeState read from JSON and not parsed yet; NodeState declared in the entity's `$config` (nested and flat) and exported nested on the text; a key the text type declares with another config; a hashtag inside a MarkNode; undo and redo; and every path that turns an entity into text: the text transform with no match, a token entity that text follows (same NodeState: it takes the text; other NodeState: kept, the text stays apart; the selection after pasted text; text with its own NodeState, which loops on `main`; a value object distinct from the text's, equal as JSON), the next entity that stops matching, adjacent entities, an entity followed by another, and a join across text in another format.

`registerLexicalTextEntityNodeStateProperty.test.ts` (new): random layouts of entities (hashtags, and token mentions) and text in several formats, with NodeState on both and MarkNodes around some, and random edits (typing, backspace and delete, joining blocks, removing ranges across nodes). Each entity has a letter no other text has; after every edit that turns an entity into text, every character of it that is still there has the entity's NodeState and MarkNode (or sits inside another entity that grew over it, which takes characters with its own NodeState, as before). 200 seeds per matcher (`TEXT_ENTITY_NODE_STATE_SEEDS`).

Before (main):

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeState.test.ts packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 × #9703: a hashtag that stops matching keeps its NodeState when it becomes text (typing before it)
 × deleting the # of a hashtag keeps its NodeState (reverse transform, no match)
 × a hashtag that becomes text merges into neighbors with the same NodeState
 × plain text with NodeState that becomes a hashtag keeps it
 × state that createNode gives the entity wins over the text it replaces
 × a reference-typed state value is carried as the same object
 × NodeState read from JSON (not parsed yet) is carried and exported
 × NodeState declared in the entity $config (nested and flat) is carried to the text
 × a key the plain text type declares with another config is parsed with that config
 × a hashtag inside a MarkNode stays inside it when it becomes text
 × undo and redo of a demotion restore the entity and the text with their NodeState
 × the text transform: the entity before it stops matching with its text (prevMatch null)
 × with the same NodeState as the text: it keeps it and takes the text
 × with other NodeState than the text: kept with its NodeState, the text stays apart
 × typed text after it: the selection stays after the typed text
 × with other NodeState than text that its match extends into, which keeps it apart: kept, each keeps its own
 × with a value object distinct from the text after it, equal as JSON: each keeps its own
 × the text transform: the entity after it stops matching (nextMatch null)
 × the reverse transform: adjacent entities both become text
 × the reverse transform: an entity followed by an entity
 × hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 × mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
      Tests  22 failed | 3 passed (25)
```

After:

```
$ pnpm exec vitest run --project unit packages/lexical-text packages/lexical-hashtag packages/lexical-mark packages/lexical-history
      Tests  1376 passed (1376)
$ pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
      Tests  103 passed (103)
$ TEXT_ENTITY_NODE_STATE_SEEDS=20000 pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 ✓ hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 ✓ mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
```

The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts` (JSON, selection, mutations and keys after random sessions, against the helper before #9483, which has no NodeState in its layouts), and `MarkStateExtension`'s hashtag tests (text inserted after a marked hashtag is not marked).

A differential against `main` over random editing sessions (typing, backspace and delete, joins across paragraphs, headings, quotes, list items and table cells, ranges, formatting, paste with NodeState, undo and redo, IME composition) with hashtags, a keyword matcher, a token and a segmented matcher, autolink and MarkNodes: the NodeState of an entity's characters is kept in tens of thousands of steps where `main` drops it; no text is lost; token and segmented entities that loop on `main` settle. Where NodeState now keeps text apart, the entities can differ from `main`, as they do next to text in another format; a keyword-style matcher can then reach #9637's loop in a layout `main` merged (a few per 10,000 sessions; hashtags never do), which #9637's fix settles.
zurfyx added a commit that referenced this pull request Nov 2, 2026
…hen they become text

## Description

`registerLexicalTextEntity` turns an entity back into plain text when it stops matching (typing right before a hashtag, deleting its `#`, joining blocks), with `$replaceWithSimpleText`. That built the new `TextNode` from the entity's text, format, style and detail, but not its NodeState, so anything an app keeps on the characters as NodeState (comment ids, suggestion marks, block ids) was dropped (#9703). The same happened in the other direction: plain text with NodeState that becomes an entity gave the entity none.

What carries, and why:

- **Demotion (entity to text): all of the entity's NodeState**, nested and flat, known and not yet parsed (read from JSON), the same value objects (ids compared by identity stay identical). NodeState is the ad-hoc data an app keeps on text; what is inherent to the entity type is its class (its fields) and its mode, which stay with the type, as before. This is what `splitText` already does for the pieces it cuts, and what the code highlighters do since #9503 when a code token becomes text. A key that the plain text type declares with a different `StateConfig` is parsed with the text type's config. Format, style and detail carry as before.
- **Promotion (text to entity): the text's NodeState too**, for every key `createNode` did not set (its state wins), with two exceptions, both characters that were just inserted into a node and so only have a copy of its NodeState: text that the entity's own transform splits off its end in the same update (text typed or pasted right after a hashtag), and an entity made only of text inserted at the start or the end of a node that existed in this update, when no entity was turned into text in that block in the update (pasting ` #tag` right after commented text: the hashtag does not join the comment, as on `main` and as `MarkStateExtension`'s rule for text inserted at a comment's edge says). `splitText` gives that piece a copy of the entity's NodeState, but those characters were just inserted, so it is not theirs; an entity made from it keeps the old behaviour (MarkStateExtension relies on telling inserted text from marked text this way). An entity turned into text and back in one update gets its own NodeState back.
- **Token and segmented entities** that text follows are turned into text so that they merge with it and are parsed again. With their NodeState kept they do not merge with text that has other NodeState, and the transforms would make the entity again and turn it back forever (on `main` this already looped whenever that text had NodeState, or another format). So, as in #9686, when the same entity (same parent, text, and place: the first node before it that is no text entity, and the size of the entities between) comes back to be turned into text a third time in one update (or updates batched into one commit), it is kept, with its NodeState. It takes the text its match extends into, as an entity in normal mode does, only when that text would merge with it (the same format, style and detail, and NodeState equal by the rule text nodes merge by: `StateConfig.isEqual`, so two distinct value objects are two values; the selection stays where it was in that text); text with other NodeState or format stays apart, so it neither takes the entity's NodeState nor makes the entity drop its own. Until the third time the transforms run as before. The count belongs to the pending editor state, so an update that fails does not count toward the next.
- The text split off the end of an entity is tracked per editor, for every registration, so an entity of another registration made from it (a keyword typed after a hashtag) does not take the copy either.
- **Core: editing inside a segmented node** replaces it with a plain `TextNode` built from its text, format and style, the same way `$replaceWithSimpleText` did, and dropped its NodeState the same way (and its detail, such as `unmergeable`, which `$replaceWithSimpleText` kept): typing inside it (outside composition, `LexicalSelection`) and removing a range that starts or ends inside it (`LexicalCaretUtils`). Both now keep them (detail, and NodeState with `$carryNodeState`, internal to `lexical`, with the same rules as above; keys the new node already holds, such as state a registered `TextNode` replacement sets, keep their value). Segmented entities that this change keeps or makes reach these paths more often, so they are fixed here.

Two text nodes merge only when their NodeState is equal, so text that keeps an entity's NodeState stays its own node next to text with other NodeState, as text in another format does. That is the point (the characters keep their comment), and it is the only structural difference: text with no NodeState merges as before.

Callers in this repo, all through `registerLexicalTextEntity`: `@lexical/hashtag` (`registerLexicalHashtag`, `HashtagExtension`, `LexicalHashtagPlugin`), `@lexical/react` `useLexicalTextEntity`, and the playground's `KeywordsExtension`. Mentions (typeahead) and `@lexical/link` autolink have their own transforms and do not share this path.

Fixes #9703

## Test plan

`.changeset/lexical-text-entity-node-state.md` (`lexical` and `@lexical/text`, patch, `[fix]`).

`MarkStateExtension.test.ts`: a hashtag pasted or inserted at the edge of a comment (after it, and before it) is not added to it, with `MarkStateExtension` and with `MarkNode` (as on `main`).

`LexicalSelection.test.ts` (3 tests, each fails on `main`): typing inside a segmented node keeps its NodeState and detail; a registered `TextNode` replacement keeps the NodeState it sets and takes the rest; removing a range from inside a segmented node to the next block keeps the NodeState and detail of what is left.

`registerLexicalTextEntityNodeState.test.ts` (new, 24 tests): #9703's repro; deleting a hashtag's `#`; merging into neighbours with the same NodeState and, without NodeState, merging as before; promotion with NodeState, `createNode`'s state winning, text inserted after a hashtag not taking the copy, and a hashtag made only of inserted text not taking it; a reference-typed value carried as the same object; NodeState read from JSON and not parsed yet; NodeState declared in the entity's `$config` (nested and flat) and exported nested on the text; a key the text type declares with another config; a hashtag inside a MarkNode; undo and redo; and every path that turns an entity into text: the text transform with no match, a token entity that text follows (same NodeState: it takes the text; other NodeState: kept, the text stays apart; the selection after pasted text; text with its own NodeState, which loops on `main`; a value object distinct from the text's, equal as JSON), the next entity that stops matching, adjacent entities, an entity followed by another, and a join across text in another format.

`registerLexicalTextEntityNodeStateProperty.test.ts` (new): random layouts of entities (hashtags, and token mentions) and text in several formats, with NodeState on both and MarkNodes around some, and random edits (typing, backspace and delete, joining blocks, removing ranges across nodes). Each entity has a letter no other text has; after every edit that turns an entity into text, every character of it that is still there has the entity's NodeState and MarkNode (or sits inside another entity that grew over it, which takes characters with its own NodeState, as before). 200 seeds per matcher (`TEXT_ENTITY_NODE_STATE_SEEDS`).

Before (main):

```
$ pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeState.test.ts packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 × #9703: a hashtag that stops matching keeps its NodeState when it becomes text (typing before it)
 × deleting the # of a hashtag keeps its NodeState (reverse transform, no match)
 × a hashtag that becomes text merges into neighbors with the same NodeState
 × plain text with NodeState that becomes a hashtag keeps it
 × state that createNode gives the entity wins over the text it replaces
 × a reference-typed state value is carried as the same object
 × NodeState read from JSON (not parsed yet) is carried and exported
 × NodeState declared in the entity $config (nested and flat) is carried to the text
 × a key the plain text type declares with another config is parsed with that config
 × a hashtag inside a MarkNode stays inside it when it becomes text
 × undo and redo of a demotion restore the entity and the text with their NodeState
 × the text transform: the entity before it stops matching with its text (prevMatch null)
 × with the same NodeState as the text: it keeps it and takes the text
 × with other NodeState than the text: kept with its NodeState, the text stays apart
 × typed text after it: the selection stays after the typed text
 × with other NodeState than text that its match extends into, which keeps it apart: kept, each keeps its own
 × with a value object distinct from the text after it, equal as JSON: each keeps its own
 × the text transform: the entity after it stops matching (nextMatch null)
 × the reverse transform: adjacent entities both become text
 × the reverse transform: an entity followed by an entity
 × hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 × mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
      Tests  22 failed | 3 passed (25)
```

After:

```
$ pnpm exec vitest run --project unit packages/lexical-text packages/lexical-hashtag packages/lexical-mark packages/lexical-history
      Tests  1378 passed (1378)
$ pnpm exec vitest run --project unit packages/lexical/src/__tests__/unit/LexicalSelection.test.ts
      Tests  103 passed (103)
$ TEXT_ENTITY_NODE_STATE_SEEDS=20000 pnpm exec vitest run --project unit packages/lexical-text/src/__tests__/unit/registerLexicalTextEntityNodeStateProperty.test.ts
 ✓ hashtag: an edit that turns an entity into text keeps its NodeState and marks on its characters
 ✓ mention: an edit that turns an entity into text keeps its NodeState and marks on its characters
```

The existing `@lexical/text` tests pass unchanged, including `registerLexicalTextEntityEquivalence.test.ts` (JSON, selection, mutations and keys after random sessions, against the helper before #9483, which has no NodeState in its layouts), and `MarkStateExtension`'s hashtag tests (text inserted after a marked hashtag is not marked).

A differential against `main` over random editing sessions (typing, backspace and delete, joins across paragraphs, headings, quotes, list items and table cells, ranges, formatting, paste with NodeState, undo and redo, IME composition) with hashtags, a keyword matcher, a token and a segmented matcher, autolink and MarkNodes: the NodeState of an entity's characters is kept in tens of thousands of steps where `main` drops it; no text is lost; token and segmented entities that loop on `main` settle. Where NodeState now keeps text apart, the entities can differ from `main`, as they do next to text in another format; a keyword-style matcher can then reach #9637's loop in a layout `main` merged (a few per 10,000 sessions; hashtags never do), which #9637's fix settles.

This branch was successfully deployed

2 active deployments
Preview – lexical — 576b48d7 Deployed Oct 29, 2026 by vercel[bot]
Preview – lexical-playground — 576b48d7 Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: [lexical-mark] Converting a paragraph to a list changes MarkStateExtension comment ids on text that did not change

3 participants