Skip to content

[lexical-markdown] Bug Fix: Keep tabs and whitespace-only inline code as code on import - #9433

Open
x-labb wants to merge 2 commits into
meta:mainfrom
x-labb:fix-9409-tab-in-inline-code
Open

x-labb wants to merge 2 commits into
meta:mainfrom
x-labb:fix-9409-tab-in-inline-code

Conversation

@x-labb

@x-labb x-labb commented Oct 22, 2026

Copy link
Copy Markdown

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.

AI disclosure: this change, its tests and this description were prepared with the help of an AI coding assistant.

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.

… as code on import

## 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 meta#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.
@vercel

vercel Bot commented Oct 22, 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 31, 2026 6:04pm UTC
lexical-playground Ready Ready Preview Oct 31, 2026 6:04pm 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 22, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 27, 2026

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/yjs

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

commit: e3cb29d

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the fix! This looks good to me. Keeping the tabs of a code-formatted text node as text, and no longer treating whitespace-only inline code as an empty paragraph, matches CommonMark's code-span rule (§6.1: code-span content is literal, and only spaces are stripped from its ends). The new tests fail on main and pass with the change. Everything below is optional and non-blocking.

Optional suggestions

  1. Changeset. AGENTS.md (Changesets) and CONTRIBUTING.md (Changesets) ask for a changeset when a published package's behavior changes, and a fix that changes output gets a Verify: paragraph. For example, .changeset/markdown-code-span-whitespace.md:

    ---
    '@lexical/markdown': patch
    ---
    
    [fix] Keep tabs and whitespace-only inline code as code when importing Markdown
    
    Verify: importing a code span that contains a tab gives one inline-code text node and exports back unchanged, and a paragraph holding only `` ` ` `` is kept instead of dropped.

    A maintainer can also add it for you.

  2. Two more test cases. isEmptyParagraph (packages/lexical-markdown/src/utils.ts:76) also decides how loose lists are joined during import (packages/lexical-markdown/src/MarkdownTransformers.ts:630) and which block separators the exporter writes (packages/lexical-markdown/src/MarkdownExport.ts:83-84). Both new behaviors are correct. - a\n\n` `\n\n- b now imports as list, paragraph, list, where it used to become one list with the code paragraph dropped. A whitespace-code paragraph between two paragraphs now exports with blank lines around it. Adding both cases to CodeSpanWhitespaceImport.test.ts would keep them from regressing.

  3. Possible follow-up, not for this PR. Pressing Tab inside inline code inserts a TabNode with the code format, and @lexical/mdast imports `a<tab>b` the same way. @lexical/markdown still exports that shape as adjacent spans, which read back as one span with backticks inside, just as it did before this change. Keeping the tab as text is the right call for the current exporter. Teaching exportTextFormat to write adjacent code-formatted nodes as one span could be its own issue.

(node.getChildrenSize() === 1 &&
$isTextNode(firstChild) &&
// Inline code is content even when it holds only whitespace.
!firstChild.hasFormat('code') &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: this check also decides list joining during import (MarkdownTransformers.ts:630) and block separators on export (MarkdownExport.ts:83-84). Both new behaviors are correct: - a\n\n` `\n\n- b now imports as list, paragraph, list, and a whitespace-code paragraph exports with blank lines around it. Neither is tested yet, and two extra cases in CodeSpanWhitespaceImport.test.ts would cover them.

This branch was successfully deployed

2 active deployments
Preview – lexical — a86e1d14 Deployed Oct 31, 2026 by vercel[bot]
Preview – lexical-playground — a86e1d14 Deployed Oct 31, 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. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants