Skip to content

Bug: A tab in inline code leaves the code span, and a paragraph of only whitespace code is dropped #9409

Open

Description

@kyoshino

Lexical version: 0.52.0 (main at 98e936d gives the same results)

Steps to reproduce

Import each document below with $convertFromMarkdownString(markdown, TRANSFORMERS), then export it with $convertToMarkdownString(TRANSFORMERS). ⇥ is a tab:

Markdown Imported Exported Markdown Expected
`a⇥b` code a, a tab node (no format), code b `a`⇥`b` code a⇥b
a `⇥` b a , a tab node (no format), b a ⇥ b code ⇥ between a and b
`⇥` alone a tab node (no format) ⇥, then an empty document on the next round trip code ⇥
x, blank line, ` `, blank line, y paragraphs x and y only x, blank line, y three paragraphs, the middle one code
a⇥b (no code) a, tab node, b ✅ unchanged ✅

Current behaviour

  1. A tab inside a code span is imported as a TabNode without the code format (or any other format of the span), so it drops out of the code. The span is split around it, and a span holding only tabs stops being code.
  2. A paragraph whose only content is inline code of up to three whitespace characters, such as ` ` or `⇥`, counts as an empty paragraph. Import removes it, and export treats it as empty when separating blocks. Code is content even when it's whitespace.

Cause

In packages/lexical-markdown/src/:

  • $normalizeMarkdownTextNode() in MarkdownImport.ts replaces each tab in every imported text node with $createTabNode(), and only the surrounding text keeps the node's format and style.
  • isEmptyParagraph() in utils.ts tests the text of a paragraph's only text node against /^\s{0,3}$/, without checking the code format. $importMarkdownNodes() in MarkdownImport.ts uses it to drop paragraphs, and createMarkdownExport() uses it to decide block separators.

Suggested fix

--- 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 part of the code span.
+  if ($isTabNode(textNode) || textNode.hasFormat('code')) {
     return;
   }
   const text = textNode.getTextContent();
--- 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 of only whitespace is content.
+      !firstChild.hasFormat('code') &&
       MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent()))
   );
 }

I kept the tab as text in the code node rather than giving the TabNode the code format. Separate code nodes on each side of a tab would be exported as adjacent code spans ( `a⇥b` ), which read back as one span with backticks in it.

With this change:

  • `a⇥b` round-trips unchanged.
  • `⇥` and a `⇥` b stay code and are stable. They're exported with padding (` ⇥ `), which a reader strips back to the tab.
  • The paragraph of only ` ` is kept. Its export still grows by two spaces on every save; that's a separate padding bug in getCodeSpanDelimiter() reported in Bug: A code span of only spaces grows by two spaces on every save, and loses its bold or italic #9405.
  • Tabs outside code (a⇥b) and in code blocks are unchanged. A truly empty paragraph is still dropped.

Tested with headless Lexical against main at 98e936d. The unit tests for lexical-markdown, lexical-mdast and lexical-playground/__tests__/unit pass (1175), along with tsc, ESLint and Prettier. No new unit tests are added. I didn't check how the editor handles a text node containing a tab when typing in a browser. Code blocks already hold tabs as TabNodes and aren't affected.

No existing issue found.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions