From e3cb29da430dd69bf797ec698e2b7faca90c0179 Mon Sep 17 00:00:00 2001 From: x-labb <154824493+x-labb@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:15:55 +0200 Subject: [PATCH] [lexical-markdown] Bug Fix: Keep tabs and whitespace-only inline code as code on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Two things made whitespace inside inline code drop out of the code on import: 1. `$normalizeMarkdownTextNode()` replaced every tab in every imported text node with a `TabNode`, and only the text around the tab kept the node's format. A tab inside a code span therefore left the code, the span was split around it, and a span holding only a tab stopped being code at all. `` `a⇥b` `` was exported back as `` `a`⇥`b` ``. 2. `isEmptyParagraph()` treated a paragraph whose only text node holds up to three whitespace characters as empty, without looking at the code format. A paragraph of only `` ` ` `` or `` `⇥` `` was removed on import, and export used the same check to separate blocks. This change keeps the tabs of an inline-code node as text, and does not count a paragraph of inline code as empty. Tabs outside code, and tabs in code blocks, are unchanged, and a paragraph that really is empty is still dropped. A tab is kept as text in the code node rather than as a `TabNode` with the code format: separate code nodes on each side of a tab would be exported as adjacent code spans, which a reader merges into one span holding backticks. The fix follows the one suggested in the issue. Closes #9409 ## Test plan ### Before `pnpm exec vitest --project unit --no-watch packages/lexical-markdown/src/__tests__/unit/CodeSpanWhitespaceImport.test.ts` ``` × keeps a tab inside a code span as part of the code × round trips a code span that holds a tab × keeps a paragraph whose only content is whitespace code AssertionError: expected [ [ { code: true, …(2) }, …(2) ] ] to deeply equal [ Array(1) ] AssertionError: expected '`a`\t`b`' to be '`a\tb`' // Object.is equality AssertionError: expected [ [ { code: false, …(2) } ], …(1) ] to deeply equal [ [ { code: false, …(2) } ], …(2) ] Tests 3 failed | 2 passed (5) ``` ### After `pnpm exec vitest --project unit --no-watch packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit` ``` Test Files 45 passed (45) Tests 1180 passed (1180) ``` `tsc`, `tsc -p tsconfig.test.json`, ESLint and Prettier pass on the changed files. Only unit tests (Node) were run. How the editor handles typing into a code-formatted text node that holds a tab was not checked in a browser. --- .../lexical-markdown/src/MarkdownImport.ts | 3 +- .../unit/CodeSpanWhitespaceImport.test.ts | 106 ++++++++++++++++++ packages/lexical-markdown/src/utils.ts | 2 + 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/lexical-markdown/src/__tests__/unit/CodeSpanWhitespaceImport.test.ts diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index e5ced6d006b..29c15742f51 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -292,7 +292,8 @@ function $importBlocks( function $normalizeMarkdownTextNode(textNode: TextNode): void { // A TabNode is a TextNode whose content is a tab, so without this guard the // rebuild below would destroy it and create an equivalent one in its place. - if ($isTabNode(textNode)) { + // Inline code keeps its tabs as text, so they stay inside the code span. + if ($isTabNode(textNode) || textNode.hasFormat('code')) { return; } const text = textNode.getTextContent(); diff --git a/packages/lexical-markdown/src/__tests__/unit/CodeSpanWhitespaceImport.test.ts b/packages/lexical-markdown/src/__tests__/unit/CodeSpanWhitespaceImport.test.ts new file mode 100644 index 00000000000..964cf5b1616 --- /dev/null +++ b/packages/lexical-markdown/src/__tests__/unit/CodeSpanWhitespaceImport.test.ts @@ -0,0 +1,106 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// @vitest-environment node + +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $convertFromMarkdownString, + $convertToMarkdownString, + TRANSFORMERS, +} from '@lexical/markdown'; +import {$getRoot, $isElementNode, $isTabNode, $isTextNode} from 'lexical'; +import {describe, expect, it} from 'vitest'; + +import {MarkdownTestExtension} from '../utils'; + +type Inline = {code: boolean; tab: boolean; text: string}; + +function importParagraphs(markdown: string): Inline[][] { + using editor = buildEditorFromExtensions([MarkdownTestExtension]); + let paragraphs: Inline[][] = []; + editor.update( + () => { + $convertFromMarkdownString(markdown, TRANSFORMERS); + paragraphs = $getRoot() + .getChildren() + .map(block => + $isElementNode(block) + ? block.getChildren().map(child => ({ + code: $isTextNode(child) && child.hasFormat('code'), + tab: $isTabNode(child), + text: child.getTextContent(), + })) + : [], + ); + }, + {discrete: true}, + ); + return paragraphs; +} + +function roundTrip(markdown: string): string { + using editor = buildEditorFromExtensions([MarkdownTestExtension]); + editor.update( + () => { + $convertFromMarkdownString(markdown, TRANSFORMERS); + }, + {discrete: true}, + ); + return editor.read(() => $convertToMarkdownString(TRANSFORMERS)); +} + +const code = (text: string): Inline => ({code: true, tab: false, text}); +const text = (value: string): Inline => ({ + code: false, + tab: false, + text: value, +}); +const tab: Inline = {code: false, tab: true, text: '\t'}; + +describe('whitespace inside inline code (#9409)', () => { + it('keeps a tab inside a code span as part of the code', () => { + expect(importParagraphs('`a\tb`')).toEqual([[code('a\tb')]]); + expect(importParagraphs('a `\t` b')).toEqual([ + [text('a '), code('\t'), text(' b')], + ]); + expect(importParagraphs('`\t`')).toEqual([[code('\t')]]); + }); + + it('round trips a code span that holds a tab', () => { + expect(roundTrip('`a\tb`')).toBe('`a\tb`'); + // A span that starts and ends with whitespace is padded, and a reader + // strips that padding back to the tab. + expect(roundTrip('`\t`')).toBe('` \t `'); + expect(roundTrip('` \t `')).toBe('` \t `'); + expect(roundTrip('a `\t` b')).toBe('a ` \t ` b'); + expect(roundTrip('a ` \t ` b')).toBe('a ` \t ` b'); + }); + + it('keeps a paragraph whose only content is whitespace code', () => { + expect(importParagraphs('x\n\n` `\n\ny')).toEqual([ + [text('x')], + [code(' ')], + [text('y')], + ]); + expect(importParagraphs('x\n\n`\t`\n\ny')).toEqual([ + [text('x')], + [code('\t')], + [text('y')], + ]); + }); + + it('still turns tabs outside code into tab nodes', () => { + expect(importParagraphs('a\tb')).toEqual([[text('a'), tab, text('b')]]); + expect(importParagraphs('a\t`b`')).toEqual([[text('a'), tab, code('b')]]); + }); + + it('still drops empty paragraphs', () => { + expect(importParagraphs('x\n\n\n\ny')).toEqual([[text('x')], [text('y')]]); + }); +}); diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index a0b11843013..846e1c570fd 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -72,6 +72,8 @@ export function isEmptyParagraph(node: LexicalNode): boolean { firstChild == null || (node.getChildrenSize() === 1 && $isTextNode(firstChild) && + // Inline code is content even when it holds only whitespace. + !firstChild.hasFormat('code') && MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent())) ); }