Repository navigation
[lexical-code-core][lexical-code-prism][lexical-code-shiki][lexical-y] Bug Fix: Highlighting keeps the NodeState of every character in a code block - #9558
Draft
zurfyx wants to merge 1 commit into
Conversation
…] Bug Fix: Highlighting keeps the NodeState of every character in a code block
## Description
When a code block is highlighted again, the highlighter replaces every token between the first and the last one that changed with fresh `CodeHighlightNode`s, and fresh nodes have no NodeState. So any edit that changes tokens on both sides of a word (two edits in one update, an annotation added over a range that starts and ends inside tokens, a quote typed before the word that turns the rest of the line into a string) drops the NodeState of the text in between: comment ids, suggestions, any annotation an app keeps in NodeState. `MarkStateExtension`'s own ids were put back by its edit tracking, but only the ones the text had before the update: a comment added in that update (over a range in a code block) was dropped too.
Found by the collaboration soak: a stale agent comments a range of a highlighted code line while another writer replaces a word in it; the word kept its comment but lost a second annotation (a set-valued NodeState key). It reproduces with no collaboration: the agent's own edit already dropped it, and the binding then wrote the loss.
Highlighting now keeps each character's state: `$keepNodeStateOf` (in `@lexical/code-core`, used by Prism and Shiki) gives the new tokens the NodeState their text had, splitting a token where the state changes, and the diff that decides which tokens to replace compares NodeState too. One rule: highlighting changes how code is shown, never the state its text carries.
`CompositionHoldBack`: a highlighted code line now keeps a remote comment the composer reads on a word it is not composing in through its own keystrokes (before, its next keystroke dropped it from its editor until the composition ended), while the hold counts the comment as held. Where the composer changes the line's type while composing, the (opt-in) self-check of that write notes the comment until the hold ends; every client then shows the same. Those 4 cases now expect exactly that diagnostic (as S7-8, the same kind of hold-versus-highlight difference); every other check of the matrix still runs on them (convergence, fresh readers, tokens, comments).
## Changeset
`.changeset/code-highlight-keep-char-state.md` (`[fix]`).
## Test plan
New `CodePrismRetokenizeNodeState.test.ts` and `CodeShikiRetokenizeNodeState.test.ts`: an annotated word through edits on both sides in one update, a quote typed before it, and part of a token annotated.
### Before
```
$ pnpm exec vitest run --project unit --reporter=verbose packages/lexical-code-prism/src/__tests__/unit/CodePrismRetokenizeNodeState.test.ts packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetokenizeNodeState.test.ts
✓ Prism (Prism) > the annotated word is unchanged
✓ Prism (Prism) > edits on both sides of the word, in one update
× Prism (Prism) > a quote typed before the word turns it into part of a string token
× Prism (Prism) > part of a token annotated
✓ Shiki (Shiki) > the annotated word is unchanged
× Shiki (Shiki) > edits on both sides of the word, in one update
× Shiki (Shiki) > a quote typed before the word turns it into part of a string token
× Shiki (Shiki) > part of a token annotated
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯
Tests 5 failed | 3 passed (8)
```
### After
```
$ pnpm exec vitest run --project unit --reporter=verbose packages/lexical-code-prism/src/__tests__/unit/CodePrismRetokenizeNodeState.test.ts packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetokenizeNodeState.test.ts
✓ Prism (Prism) > the annotated word is unchanged
✓ Prism (Prism) > edits on both sides of the word, in one update
✓ Prism (Prism) > a quote typed before the word turns it into part of a string token
✓ Prism (Prism) > part of a token annotated
✓ Shiki (Shiki) > the annotated word is unchanged
✓ Shiki (Shiki) > edits on both sides of the word, in one update
✓ Shiki (Shiki) > a quote typed before the word turns it into part of a string token
✓ Shiki (Shiki) > part of a token annotated
Tests 8 passed (8)
```
- Unit suites of `@lexical/y`, the code packages, `@lexical/mark`, `@lexical/markdown`, `@lexical/html`, `@lexical/clipboard` and the playground: all pass (258 files, 9,492 passed).
- The soak repro (a stale comment over a code line while its word is replaced, in a paragraph, a code block and a code block turned into a list, both orders): all pass; 6 failed before.
zurfyx
requested review from
acywatson,
ivailop7 and
potatowagon
as code owners
October 30, 2026 00:10
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
zurfyx
marked this pull request as draft
October 30, 2026 01:20
Member
Author
|
Converting to draft: a randomized collaboration soak (same seeds with and without this change) found a regression this change introduces. 5 of 1,500 sessions differ, all reproducing deterministically with this PR alone on
The node splits this change adds at state boundaries go through the writer's handling of replaced text nodes, which writes a removed node's text whole or not at all, so a short split piece next to a paste is written again. Keeping the per-character state is still the right behavior; it needs the writer to keep characters across such splits first. Leaving this as a draft until then. |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
When a code block is highlighted again, the highlighter replaces every token between the first and the last one that changed with fresh
CodeHighlightNodes, and fresh nodes have no NodeState. So any edit that changes tokens on both sides of a word (two edits in one update, an annotation added over a range that starts and ends inside tokens, a quote typed before the word that turns the rest of the line into a string) drops the NodeState of the text in between: comment ids, suggestions, any annotation an app keeps in NodeState.MarkStateExtension's own ids were put back by its edit tracking, but only the ones the text had before the update: a comment added in that update (over a range in a code block) was dropped too.Found by the collaboration soak: a stale agent comments a range of a highlighted code line while another writer replaces a word in it; the word kept its comment but lost a second annotation (a set-valued NodeState key). It reproduces with no collaboration: the agent's own edit already dropped it, and the binding then wrote the loss.
Highlighting now keeps each character's state:
$keepNodeStateOf(in@lexical/code-core, used by Prism and Shiki) gives the new tokens the NodeState their text had, splitting a token where the state changes, and the diff that decides which tokens to replace compares NodeState too. One rule: highlighting changes how code is shown, never the state its text carries.CompositionHoldBack: a highlighted code line now keeps a remote comment the composer reads on a word it is not composing in through its own keystrokes (before, its next keystroke dropped it from its editor until the composition ended), while the hold counts the comment as held. Where the composer changes the line's type while composing, the (opt-in) self-check of that write notes the comment until the hold ends; every client then shows the same. Those 4 cases now expect exactly that diagnostic (as S7-8, the same kind of hold-versus-highlight difference); every other check of the matrix still runs on them (convergence, fresh readers, tokens, comments).Changeset
.changeset/code-highlight-keep-char-state.md([fix]).Test plan
New
CodePrismRetokenizeNodeState.test.tsandCodeShikiRetokenizeNodeState.test.ts: an annotated word through edits on both sides in one update, a quote typed before it, and part of a token annotated.Before
After
@lexical/y, the code packages,@lexical/mark,@lexical/markdown,@lexical/html,@lexical/clipboardand the playground: all pass (258 files, 9,492 passed).