fix: prevent crash when pressing Shift+Enter in empty code block

The CodeHighlightPlugin's selection restoration could create an invalid
Lexical selection targeting a LineBreakNode with type 'element'. Since
LineBreakNode is not an ElementNode, Lexical threw during reconciliation
and the LexicalErrorBoundary replaced the editor with an error state.

The fix ensures findPoint never targets a LineBreakNode directly.
Instead it falls back to an element-level selection on the parent
CodeNode using the child index, which is always a valid target.

Extracted the selection helpers (getCodeNodeAbsoluteOffset,
findCodeNodeSelectionPoint, restoreCodeNodeSelection) into
markdownEditor.ts for testability and added regression tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-29 00:12:20 +01:00
co-authored by Copilot
parent 21f0ccb184
commit 48efbf36f9
3 changed files with 216 additions and 57 deletions
+5 -55
View File
@@ -24,7 +24,6 @@ import {
$isCodeNode,
$createCodeNode,
$createCodeHighlightNode,
$isCodeHighlightNode,
CodeNode,
CodeHighlightNode,
} from '@lexical/code';
@@ -44,9 +43,7 @@ import {
$getNodeByKey,
$getRoot,
$getSelection,
$isLineBreakNode,
$isRangeSelection,
$isTextNode,
CLEAR_EDITOR_COMMAND,
COMMAND_PRIORITY_HIGH,
FORMAT_TEXT_COMMAND,
@@ -63,6 +60,8 @@ import {
markdownEditorNamespace,
markdownEditorNodes,
markdownEditorTransformers,
getCodeNodeAbsoluteOffset,
restoreCodeNodeSelection,
} from '@renderer/lib/markdownEditor';
import { prepareChatMessageContent } from '@shared/utils/chatMessage';
@@ -295,55 +294,6 @@ function parseHljsHtml(html: string): HljsToken[] {
/* ── Code highlight plugin ────────────────────────────── */
function getAbsoluteOffset(
codeNode: ReturnType<typeof $getNodeByKey>,
point: { key: string; offset: number },
): number {
if (!codeNode || !('getChildren' in codeNode)) return 0;
let offset = 0;
for (const child of (codeNode as CodeNode).getChildren()) {
if (child.getKey() === point.key) return offset + point.offset;
offset += $isLineBreakNode(child) ? 1 : child.getTextContentSize();
}
return offset;
}
function restoreSelectionFromOffsets(codeNode: CodeNode, anchorOff: number, focusOff: number) {
const children = codeNode.getChildren();
function findPoint(target: number) {
let offset = 0;
for (const child of children) {
const size = $isLineBreakNode(child) ? 1 : child.getTextContentSize();
if (offset + size > target || (offset + size === target && $isTextNode(child))) {
return {
key: child.getKey(),
offset: target - offset,
type: ($isTextNode(child) || $isCodeHighlightNode(child) ? 'text' : 'element') as 'text' | 'element',
};
}
offset += size;
}
const last = children[children.length - 1];
if (last) {
return {
key: last.getKey(),
offset: $isLineBreakNode(last) ? 0 : last.getTextContentSize(),
type: ($isTextNode(last) || $isCodeHighlightNode(last) ? 'text' : 'element') as 'text' | 'element',
};
}
return { key: codeNode.getKey(), offset: 0, type: 'element' as const };
}
const anchor = findPoint(anchorOff);
const focus = findPoint(focusOff);
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.anchor.set(anchor.key, anchor.offset, anchor.type);
selection.focus.set(focus.key, focus.offset, focus.type);
}
}
/** Enables highlight.js-based syntax highlighting inside CodeNodes. */
function CodeHighlightPlugin() {
const [editor] = useLexicalComposerContext();
@@ -369,8 +319,8 @@ function CodeHighlightPlugin() {
let anchorOff: number | undefined;
let focusOff: number | undefined;
if ($isRangeSelection(sel)) {
anchorOff = getAbsoluteOffset(current, sel.anchor);
focusOff = getAbsoluteOffset(current, sel.focus);
anchorOff = getCodeNodeAbsoluteOffset(current, sel.anchor);
focusOff = getCodeNodeAbsoluteOffset(current, sel.focus);
}
// Build new children from tokens
@@ -392,7 +342,7 @@ function CodeHighlightPlugin() {
// Restore cursor
if (anchorOff !== undefined && focusOff !== undefined) {
restoreSelectionFromOffsets(current, anchorOff, focusOff);
restoreCodeNodeSelection(current, anchorOff, focusOff);
}
});
queueMicrotask(() => highlightingKeys.delete(nodeKey));
+79 -2
View File
@@ -1,9 +1,16 @@
import { CodeHighlightNode, CodeNode } from '@lexical/code';
import { $isCodeHighlightNode, CodeHighlightNode, CodeNode } from '@lexical/code';
import { AutoLinkNode, LinkNode } from '@lexical/link';
import { type Transformer, TRANSFORMERS } from '@lexical/markdown';
import { ListItemNode, ListNode } from '@lexical/list';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { type Klass, type LexicalNode } from 'lexical';
import {
$getSelection,
$isLineBreakNode,
$isRangeSelection,
$isTextNode,
type Klass,
type LexicalNode,
} from 'lexical';
import { normalizeChatMessageLineEndings } from '@shared/utils/chatMessage';
@@ -96,3 +103,73 @@ export function inspectMarkdownPaste(text: string): MarkdownPasteInspection {
export function shouldImportMarkdownPaste(text: string): boolean {
return inspectMarkdownPaste(text).shouldImportMarkdown;
}
/* ── Code-node selection helpers ──────────────────────── */
/**
* Returns the absolute character offset of a selection point within a CodeNode.
* LineBreakNodes count as 1 character; all other children use their text content size.
*/
export function getCodeNodeAbsoluteOffset(
codeNode: CodeNode,
point: { key: string; offset: number },
): number {
let offset = 0;
for (const child of codeNode.getChildren()) {
if (child.getKey() === point.key) return offset + point.offset;
offset += $isLineBreakNode(child) ? 1 : child.getTextContentSize();
}
return offset;
}
/**
* Converts an absolute character offset within a CodeNode into a valid Lexical
* selection point (node key, local offset, and point type).
*
* The returned point always targets either a text-like child (`type: 'text'`)
* or the parent CodeNode itself (`type: 'element'`). It never targets a
* LineBreakNode directly — doing so would crash Lexical because LineBreakNode
* is not an ElementNode.
*/
export function findCodeNodeSelectionPoint(
codeNode: CodeNode,
target: number,
): { key: string; offset: number; type: 'text' | 'element' } {
const children = codeNode.getChildren();
let offset = 0;
for (let i = 0; i < children.length; i++) {
const child = children[i];
const size = $isLineBreakNode(child) ? 1 : child.getTextContentSize();
if (offset + size > target || (offset + size === target && $isTextNode(child))) {
if ($isTextNode(child) || $isCodeHighlightNode(child)) {
return { key: child.getKey(), offset: target - offset, type: 'text' as const };
}
// Non-text child (e.g. LineBreakNode): target the parent CodeNode at this child index
return { key: codeNode.getKey(), offset: i, type: 'element' as const };
}
offset += size;
}
const last = children.length > 0 ? children[children.length - 1] : undefined;
if (last && ($isTextNode(last) || $isCodeHighlightNode(last))) {
return { key: last.getKey(), offset: last.getTextContentSize(), type: 'text' as const };
}
return { key: codeNode.getKey(), offset: children.length, type: 'element' as const };
}
/**
* Restores a range selection within a CodeNode from absolute character offsets.
* Must be called inside an editor update or read context.
*/
export function restoreCodeNodeSelection(
codeNode: CodeNode,
anchorOff: number,
focusOff: number,
): void {
const anchor = findCodeNodeSelectionPoint(codeNode, anchorOff);
const focus = findCodeNodeSelectionPoint(codeNode, focusOff);
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.anchor.set(anchor.key, anchor.offset, anchor.type);
selection.focus.set(focus.key, focus.offset, focus.type);
}
}
+132
View File
@@ -1,12 +1,26 @@
import { describe, expect, test } from 'bun:test';
import { createHeadlessEditor } from '@lexical/headless';
import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown';
import {
$createCodeNode,
$createCodeHighlightNode,
CodeNode,
} from '@lexical/code';
import {
$createLineBreakNode,
$createRangeSelection,
$getRoot,
$setSelection,
} from 'lexical';
import {
findCodeNodeSelectionPoint,
getCodeNodeAbsoluteOffset,
inspectMarkdownPaste,
markdownEditorNamespace,
markdownEditorNodes,
markdownEditorTransformers,
restoreCodeNodeSelection,
} from '@renderer/lib/markdownEditor';
function roundTripMarkdown(markdown: string): string {
@@ -71,3 +85,121 @@ describe('markdown editor contract', () => {
});
});
});
/* ── Code-node selection helpers ──────────────────────── */
function createTestEditor() {
return createHeadlessEditor({
namespace: markdownEditorNamespace,
nodes: [...markdownEditorNodes],
onError(error) {
throw error;
},
});
}
describe('code-node selection helpers', () => {
test('findCodeNodeSelectionPoint targets CodeNode (not LineBreakNode) for linebreak-only content', () => {
const editor = createTestEditor();
editor.update(() => {
const codeNode = $createCodeNode();
codeNode.append($createLineBreakNode());
$getRoot().clear().append(codeNode);
// target=0 → before the LineBreakNode
const before = findCodeNodeSelectionPoint(codeNode, 0);
expect(before.key).toBe(codeNode.getKey());
expect(before.offset).toBe(0);
expect(before.type).toBe('element');
// target=1 → after the LineBreakNode (fallback)
const after = findCodeNodeSelectionPoint(codeNode, 1);
expect(after.key).toBe(codeNode.getKey());
expect(after.offset).toBe(1);
expect(after.type).toBe('element');
}, { discrete: true });
});
test('findCodeNodeSelectionPoint targets text nodes correctly', () => {
const editor = createTestEditor();
editor.update(() => {
const codeNode = $createCodeNode();
codeNode.append($createCodeHighlightNode('hello'));
codeNode.append($createLineBreakNode());
codeNode.append($createCodeHighlightNode('world'));
$getRoot().clear().append(codeNode);
// target=3 → inside "hello"
const mid = findCodeNodeSelectionPoint(codeNode, 3);
expect(mid.key).toBe(codeNode.getChildren()[0].getKey());
expect(mid.offset).toBe(3);
expect(mid.type).toBe('text');
// target=5 → end of "hello" (text boundary)
const endHello = findCodeNodeSelectionPoint(codeNode, 5);
expect(endHello.key).toBe(codeNode.getChildren()[0].getKey());
expect(endHello.offset).toBe(5);
expect(endHello.type).toBe('text');
// target=6 → after the LineBreakNode → start of "world"
const startWorld = findCodeNodeSelectionPoint(codeNode, 6);
expect(startWorld.key).toBe(codeNode.getChildren()[2].getKey());
expect(startWorld.offset).toBe(0);
expect(startWorld.type).toBe('text');
}, { discrete: true });
});
test('findCodeNodeSelectionPoint handles empty CodeNode', () => {
const editor = createTestEditor();
editor.update(() => {
const codeNode = $createCodeNode();
$getRoot().clear().append(codeNode);
const point = findCodeNodeSelectionPoint(codeNode, 0);
expect(point.key).toBe(codeNode.getKey());
expect(point.offset).toBe(0);
expect(point.type).toBe('element');
}, { discrete: true });
});
test('restoreCodeNodeSelection does not throw on linebreak-only CodeNode', () => {
const editor = createTestEditor();
editor.update(() => {
const codeNode = $createCodeNode();
codeNode.append($createLineBreakNode());
$getRoot().clear().append(codeNode);
// Create a valid selection so restoreCodeNodeSelection has one to update
const sel = $createRangeSelection();
sel.anchor.set(codeNode.getKey(), 0, 'element');
sel.focus.set(codeNode.getKey(), 0, 'element');
$setSelection(sel);
// This would throw before the fix because findPoint returned a point
// targeting the LineBreakNode with type 'element'
expect(() => restoreCodeNodeSelection(codeNode, 1, 1)).not.toThrow();
}, { discrete: true });
});
test('getCodeNodeAbsoluteOffset computes character offsets correctly', () => {
const editor = createTestEditor();
editor.update(() => {
const codeNode = $createCodeNode();
const hello = $createCodeHighlightNode('hello');
const lb = $createLineBreakNode();
const world = $createCodeHighlightNode('world');
codeNode.append(hello, lb, world);
$getRoot().clear().append(codeNode);
expect(getCodeNodeAbsoluteOffset(codeNode, { key: hello.getKey(), offset: 0 })).toBe(0);
expect(getCodeNodeAbsoluteOffset(codeNode, { key: hello.getKey(), offset: 3 })).toBe(3);
expect(getCodeNodeAbsoluteOffset(codeNode, { key: lb.getKey(), offset: 0 })).toBe(5);
expect(getCodeNodeAbsoluteOffset(codeNode, { key: world.getKey(), offset: 2 })).toBe(8);
}, { discrete: true });
});
});