Skip to content

[lexical][lexical-markdown][lexical-selection] Bug Fix: Keep Markdown export's format markers balanced around formatted whitespace, in linear time - #9420

Merged
etrepum merged 30 commits into
meta:mainfrom
fixfon:fix/9403-markdown-export
Oct 29, 2026
Merged

etrepum merged 30 commits into
meta:mainfrom
fixfon:fix/9403-markdown-export

Conversation

@fixfon

@fixfon fixfon commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

Markdown export dropped, repeated or misplaced closing markers around a text node made up only of whitespace whose formats differ from the text beside it. Whether a tag stayed open past a node was decided by the next sibling alone, and a whitespace-only sibling can't hold a tag, so:

  • the early return for whitespace-only text dropped the tags that closed there: **_a_ [b](u)** exported as ***a* [**b**](u);
  • the next node reopened a tag the space had kept open: bold a, bold italic , bold c exported as **a **c****;
  • a closer could land after a space, where it isn't right-flanking: bold a, bold italic , bold strikethrough , plain c exported as **a ** c;
  • $convertSelectionToMarkdownString read siblings' whole text past the end of the selection, leaving tags unclosed: bold a, bold italic bc, bold d, cut after b, exported as **a*b*.

Now the whitespace-only early return keeps its closing tags, and the keep-open check ($continuesTag) looks past whitespace-only siblings that keep the format, stopping at a newline (import pairs tags within one line) and at the end of the selection, where it reads each sibling's selected slice. A whitespace-only previous node with the format counts as having it, so the tag isn't reopened. One canHoldTag(text) decides whether text can carry a tag for both the exported node and the siblings it looks at, replacing checkHasFormat. Empty inline code, such as inline code a selection only touches, is left out like empty text. The examples above now export ***a*** [**b**](u), **a c**, **a** c and **a*b***.

Export stays linear. The keep-open answer is remembered per node on the open tag, so each run of formatted spaces is walked once. Selection export used to rebuild the selection's nodes and range for every node it visited, which made it quadratic on main already. It now works on a copy of the selection that caches its nodes and direction. LexicalNode.isSelected uses a cached key set when asked repeatedly about a selection whose nodes are cached. $sliceSelectedTextNodeContent returns early for a node that no text point is in, which also helps HTML and clipboard export. The whitespace split around tags uses trimStart/trimEnd instead of a regex that backtracks over long runs of spaces.

Closes #9403

Changeset

.changeset/markdown-formatted-whitespace-export.md: [fix] for @lexical/markdown, lexical and @lexical/selection, with a Verify: paragraph for Markdown export of text where a space carries different formatting than the words around it.

Test plan

Before

./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "whitespace"   # against main's MarkdownExport.ts

 × round-trips "**_a_ [b](u)**" with a stable export
 × round-trips "**_a_ [b](u) c**" with a stable export
 × round-trips "*a **b** [c](u)*" with a stable export
 × closes before unformatted text
 × closes at the end of a link
 × closes before consecutive formatted spaces
 × closes before the trailing space of the text it formats
 × does not reopen a tag it kept open
 × keeps a tag open across consecutive formatted spaces
 × closes before a formatted line break
 × keeps a tag open across inline code that holds only a space
 × closes a tag before a formatted space at the end of a selected link
 × closes the tags of the previous node before a slice cut down to whitespace
 × keeps a tag open across consecutive formatted spaces
 × closes before a slice cut down to a formatted space
 × closes when the format continues only after the selection
 × closes every tag at the end of a sliced node
 × leaves out inline code the selection only touches
 × leaves out inline code the selection starts at the end of

 Tests  19 failed | 34 passed | 578 skipped (631)

After

./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "whitespace"

 Tests  53 passed | 578 skipped (631)

The new "visits each sibling a bounded number of times" tests count sibling reads while exporting 200 alternating formatted spaces (document, text-point selection, element-point selection). Each runs in under 20 ms, and they fail on a quadratic walk (40,904 and 10,809 reads against a limit of 2,000 before this change).

…a formatted space

## Description

When a text node made up only of whitespace is the last text sibling
before a link (for example the space in `**_a_ [b](u)**`),
`exportTextFormat()` moved the tags that close at that node into
`closingTagsAfter` and removed them from `unclosedTags`, but the
whitespace-only early return only returned `closingTagsBefore + output`.
The closing `**` or `*` was dropped from the output, so
`**_a_ [b](u)**` exported as `***a* [**b**](u)` and, exported a second
time, as `\*\**a* [**b**](u)`. `**_a_ [b](u) c**` and
`*a **b** [c](u)*` were broken the same way.

The early return now also includes `closingTagsAfter`. Whitespace-only
nodes still never get opening tags and the closing tags stay next to the
preceding non-whitespace character, so flanking rules are respected.

Closes meta#9403

## Test plan

### Before

```
pnpm exec vitest --project unit --no-watch packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "formatted whitespace before a link"

 × round-trips "**_a_ [b](u)**" with a stable export
 × round-trips "**_a_ [b](u) c**" with a stable export
 × round-trips "*a **b** [c](u)*" with a stable export
 ✓ round-trips "**a [b](u)**" with a stable export

 Tests  3 failed | 1 passed | 610 skipped (614)
```

Exported markdown (export, then import and export again):

```
**_a_ [b](u)**    -> ***a* [**b**](u)       -> \*\**a* [**b**](u)
**_a_ [b](u) c**  -> ***a* [**b**](u) **c** -> \*\**a* [**b**](u) **c**
*a **b** [c](u)*  -> *a **b** [*c*](u)      -> \*a **b** [*c*](u)
```

### After

```
pnpm exec vitest --project unit --no-watch packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "formatted whitespace before a link"

 ✓ round-trips "**_a_ [b](u)**" with a stable export
 ✓ round-trips "**_a_ [b](u) c**" with a stable export
 ✓ round-trips "*a **b** [c](u)*" with a stable export
 ✓ round-trips "**a [b](u)**" with a stable export

 Tests  4 passed | 610 skipped (614)
```

Exported markdown, stable on the second pass:

```
**_a_ [b](u)**    -> ***a*** [**b**](u)
**_a_ [b](u) c**  -> ***a*** [**b**](u) **c**
*a **b** [c](u)*  -> *a **b*** [*c*](u)
```

Also run: `vitest --project unit` for `packages/lexical-markdown` and
`packages/lexical-mdast` (15 files, 916 tests passed), eslint and
prettier on the two touched files, and `tsc` over
`packages/lexical-markdown/src` only (no errors).

Not exercised: Node 24.21.0 from `.nvmrc` (everything ran on Node
22.23.2), the browser and e2e tests, and the repo-wide `pnpm run tsc`
and `pnpm run flow`.
@vercel

vercel Bot commented Oct 21, 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 29, 2026 10:04pm UTC
lexical-playground Ready Ready Preview Oct 29, 2026 10:04pm UTC

Request Review

@pkg-pr-new

pkg-pr-new Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9420

@lexical/yjs

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

commit: b2a3fe5

…e, add changeset

## Description

A whitespace-only text node never emits tags, but one that carries a format its next neighbour also has keeps that tag open. The next node then checked `checkHasFormat(prevNode)`, which treats whitespace-only nodes as unformatted, so it opened the tag a second time: bold `a`, bold italic ` `, bold `c` exported as `**a **c****`. It now checks `hasFormat(prevNode)`, and still opens the tag when it is not actually open.

Adds tests for a formatted space before unformatted text, at the end of a link, and between two runs of the same format, and the changeset for the PR.

## Test plan

### Before

```
pnpm vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "formatted whitespace"
× does not reopen a tag it kept open
Received: "**a **c****"
```

### After

```
Tests  7 passed | 610 skipped (617)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Oct 29, 2026
claude added 12 commits October 29, 2026 16:03
## Description

Whether a tag stays open past a text node was decided by the next sibling alone. When that sibling was whitespace-only, the tag stayed open and was closed by the whitespace node, after a space where it cannot close anything: bold `a`, bold italic ` `, bold strikethrough ` `, plain `c` exported as `**a ** c`, and bold `a ` followed by a bold italic space as `**a ** c` too. The decision now looks past whitespace-only siblings that keep the format, so the tag closes on the last text that can hold it, before its trailing space. Also renames `exportTextFormat` to `$exportTextFormat`, since it reads node state, and drops a `code` check that `isWhitespaceOnly` already rules out.

## Test plan

### Before

```
./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "whitespace"
× closes before consecutive formatted spaces
× closes before the trailing space of the text it formats
× does not reopen a tag it kept open
× keeps a tag open across consecutive formatted spaces
```

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown
Test Files  10 passed (10)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ction

## Description

Selection export decided whether a tag continues from each sibling's whole text, even past the end of the selection, so a tag whose format went on after the selection was never closed (bold `a`, bold italic `bc`, bold `d`, selected up to `b`, exported `**a*b*`), and a sibling cut down to a space still counted as text that holds the tag (`**a ** `). `$exportTextFormat` now takes the selection, and the look-ahead stops at it and reads each sibling's selected slice. A whitespace-only inline code node also counts as able to hold a tag, since it is a fenced span, so bold `a` followed by a bold code space keeps the bold on both. The look-ahead only runs when the node has the format.

## Test plan

### Before

```
./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "whitespace"
× keeps a tag on inline code that holds only a space
× closes before a slice cut down to a formatted space
× closes when the format continues only after the selection
× closes every tag at the end of a sliced node
```

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## Description

Whether exported text can carry a format's tag was decided in three places (`checkHasFormat`, the whitespace match, and the look-ahead), from whole-node text in some and the exported slice in others. `canHoldTag(node, text)` now decides it for both the node being exported and the siblings the look-ahead reads, and `checkHasFormat` is gone. An inline code node whose selected slice is empty can no longer hold a tag, so a selection that only touches inline code no longer emits an empty code span inside the tag. Drops the duplicated `nodeHasFormat` checks in the close loop.

## Test plan

### Before

```
./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "touches"
× leaves out inline code the selection only touches
Received: "**a`  `**"
```

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## Description

The look-ahead that keeps a tag open across formatted whitespace also looked past whitespace holding a newline, so bold `a`, bold italic `\n`, bold `c` exported as `**a\nc**`, which markdown import cannot match because it pairs tags within one line. A newline now ends the look-ahead, and the export is `**a**\n**c**`. Adds a selection test that starts at the end of an inline code node, and names the other format markers in the changeset.

## Test plan

### Before

```
./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "line break"
× closes before a formatted line break
Received: "**a\nc**"
```

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eset

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…next line

## Description

The look-ahead checked for a newline only after deciding whether a sibling could hold the tag, so a sibling that starts on the next line (bold `a`, bold italic `\nc`) kept `**` open across the line break, which markdown import cannot match. The newline check now comes first. Also moves the inline code and whitespace split to after the early return, so text that cannot hold a tag skips the split, and renames `isWhitespaceOnly` to `cannotHoldTag`, since it also covers empty inline code.

## Test plan

### Before

```
./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "next line"
× closes before formatted text on the next line
```

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…name cannotHoldTag to holdsTag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…again

## Description

Checking for a newline before asking whether a sibling can hold the tag closed and reopened a tag between same-format nodes whose second node holds text around a newline (bold `a`, bold `b\nc` kept apart by a style), exporting `**a****b\nc**` where main exported `**ab\nc**`. The newline now only stops the look-ahead at whitespace-only siblings, as before. Selection export reads a node's emitted text through one `$exportedText` helper in all three places, and a selection test covers a formatted space at the end of a link.

## Test plan

### Before

```
./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "selected link"   # against main's exporter
× closes a tag before a formatted space at the end of a selected link
```

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lds only spaces

## Description

Letting space-only inline code hold format tags exported tags its neighbours don't share, next to a code fence, where CommonMark flanking fails: plain `a`, bold code ` `, plain `b` exported ``a**`   `**b`` and re-imported with literal asterisks, where main exported ``a`   `b``. `canHoldTag` is back to "has non-whitespace text" for every node, as main decided, and only empty inline code is left out of the export.

## Test plan

### Before

Probe against this branch's previous head: ``a**`   `**b``.

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## Description

Adds round-trip tests for space-only inline code with another format: between plain text it must not gain tags (an earlier head of this branch exported ``a**`   `**b``), and between bold text the bold run stays open across it.

## Test plan

### Before

```
./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "only a space"   # against a13106f's exporter
× leaves the formats of inline code that holds only a space off
```

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown
Test Files  10 passed (10)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## Description

A selection-path call that left out the trailing `selection` argument would still type-check and run the look-ahead over the whole document, bringing back unclosed tags at the end of a selection. `$exportTextFormat`'s last three parameters are now required, and the full-document calls pass `null`. The space-only inline code tests now also match the exported string (padding left open), and a selection test is renamed to what it now covers.

## Test plan

### After

```
./node_modules/.bin/tsc -p ./tsconfig.test.json
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude and others added 3 commits October 29, 2026 17:55
…heir tables

## Description

The space-only inline code cases join the formatted-whitespace table (which now takes a string or a pattern), and the two standalone selection tests join the selection table, which now names the start and end node of the selection.

## Test plan

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown
Tests  820 passed (820)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…head

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@etrepum etrepum changed the title [lexical-markdown] Bug Fix: Keep closing ** or * before a link after a formatted space [lexical-markdown] Bug Fix: Keep format markers balanced around formatted whitespace in Markdown export Oct 29, 2026
… Markdown export

## Description

The look-ahead that keeps a tag open across formatted whitespace walked from every node of a run to its end, so a long run of alternating formatted spaces made export quadratic (6.1 s for 8,000 nodes, against 18 ms on main). The open tag now remembers the node that kept it open and the later text that holds it, so the rest of the run reuses that answer, and each sibling is visited a bounded number of times.

Selection export also asked the selection for its nodes once per node, and a read-only selection doesn't cache them, so each call rebuilt the whole range; that made it quadratic on main already. It now works on a copy of the selection that caches its nodes.

## Test plan

### Before

```
./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "bounded"   # against 8faba12
AssertionError: expected 126254 to be less than 5000
```

Scratch benchmark, 1 bold `a`, N alternating bold italic / bold strikethrough spaces, bold `c` (full export): 8faba12 6.1 s at 8,000.

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Same benchmark: full export 4 / 11 / 18 ms at 1,000 / 4,000 / 16,000 nodes; selection export 33 / 174 / 2,510 ms (main: 351 / 4,959 ms at 1,000 / 4,000).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ey set

## Description

`LexicalNode.isSelected` scanned `selection.getNodes()` on every call, so anything that asks it for every node of a selection, such as `$convertSelectionToMarkdownString` (directly and through `$sliceSelectedTextNodeContent`), was quadratic in the selection's size. When the selection caches its nodes, a Set of their keys is now cached with that node list (in a WeakMap keyed by the list), so each check is O(1). A selection that doesn't cache its nodes, such as a read-only one, still scans; Markdown selection export now works on a copy that caches them. The changeset now lists `lexical` too.

## Test plan

### Before

Scratch benchmark, selection export of bold `a`, N alternating formatted spaces, bold `c`: 33 / 174 / 2,510 ms at 1,000 / 4,000 / 16,000 nodes.

### After

Same benchmark: 39 / 44 / 51 / 200 ms at 1,000 / 4,000 / 16,000 / 64,000 nodes (full export 45 ms at 64,000).

```
./node_modules/.bin/vitest run packages/lexical/src packages/lexical-selection packages/lexical-html packages/lexical-markdown packages/lexical-clipboard
Test Files  172 passed (172)
./node_modules/.bin/vitest run scripts/__tests__/unit/treeShakingSource.test.ts
Test Files  1 passed (1)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inear for any selection

## Description

Selection export still sliced every exported text node with `$sliceSelectedTextNodeContent`, which works out the selection's direction and, for element points, its caret range on every call, so a selection ending mid-paragraph was quadratic. Only the text nodes at the selection's ends can have a slice that differs from their whole text, so those are found once per export and the rest use their text directly. `isSelected`'s key Set is now built only on a repeat lookup of the same cached node list, so an update that writes between lookups doesn't allocate one per call. The linearity test now selects to the middle of the paragraph with text points and from the middle with element points, counts both sibling directions, and uses 200 spaces (each case runs in under 20 ms).

## Test plan

### Before

```
./node_modules/.bin/vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "bounded"   # against 820f131's MarkdownExport.ts
× visits each sibling a bounded number of times exporting a selection between text points
× visits each sibling a bounded number of times exporting a selection between element points
AssertionError: expected 40904 to be less than 2000
AssertionError: expected 10809 to be less than 2000
```

Scratch benchmark, N alternating bold/italic `x` nodes, read-only selection export: text points to the middle 224 ms / 5,328 ms at 1,000 / 4,000; element points from the middle 106 ms / 1,877 ms.

### After

Same benchmark: 35 / 33 ms and 5 / 16 ms.

```
./node_modules/.bin/vitest run packages/lexical/src packages/lexical-selection packages/lexical-html packages/lexical-markdown packages/lexical-clipboard packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts scripts/__tests__/unit/treeShakingSource.test.ts
Test Files  182 passed (182)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aths in Markdown export

## Description

- The whitespace split around formatting tags used `/^(\s*)(.*?)(\s*)$/s`, which backtracks quadratically over a long run of spaces inside one text node; it now uses `trim`/`trimStart`, which is linear and needs no fallback.
- Selection export caches the selection's direction on its copy as well as its nodes, since slicing a node asks for it and a read-only selection doesn't cache it.
- The copy is made after `getNodes()`, which can adjust a table selection's points.
- `isSelected` returns early for an unselected node, so an element-point range no longer works out its direction for every inline decorator outside it.

## Test plan

### After

```
./node_modules/.bin/vitest run packages/lexical/src packages/lexical-selection packages/lexical-html packages/lexical-markdown packages/lexical-clipboard packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts scripts/__tests__/unit/treeShakingSource.test.ts
Test Files  182 passed (182)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## Description

`$continuesTag` remembered only a positive answer, so a tag that stayed open after a negative one (an unclosable tag in a text match transformer's children) walked the rest of the run from every node. The tag now remembers the answer and the sibling the walk stopped at, which every node in between shares. Also drops a redundant caret-range pass when finding the selection's end nodes (only text points produce text slices), and trims the flanking whitespace in one pass from each end.

## Test plan

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de table

## Description

Only the node of a text point can be cut short by a selection, so `$exportedText` now compares the node with the selection's points directly instead of looking the end nodes up in a module-level table keyed by the selection copy; a selection that a transformer passes in stays on the fast path too. `$continuesTag` reuses the previous text sibling that `$exportTextFormat` already looked up.

## Test plan

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dependent of call order

## Description

The look-ahead memo only held while the same tag was asked about every node of a run in order; when an earlier unclosable tag cut the close loop short, a later tag's memo went stale and the run was walked again from each node. Every whitespace node a walk passes over shares its answer, so the tag now records the answer for each of them by key, and a run is walked once whatever order its nodes are asked in.

## Test plan

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… that already has an answer

## Description

A walk now stops at a whitespace sibling the tag already answered for and takes its answer, so a run is walked once even when its nodes are asked right to left. The answers map is created with the open tag instead of lazily, and a walk that passes no whitespace stores its one answer without an intermediate array.

## Test plan

### After

```
./node_modules/.bin/vitest run packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts
Test Files  19 passed (19)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iceSelectedTextNodeContent for nodes no text point is in

## Description

Only the node of a text point can be cut short by a selection (element points never shorten a text node, since only text points produce text slices). `$sliceSelectedTextNodeContent` now checks that before `isSelected` and the slice computation, returning the node itself as it already did for those nodes, so any caller that slices every selected node (HTML and clipboard export as well as Markdown) no longer works out the selection for each one. Markdown's own copy of that rule is removed, and the changeset lists `@lexical/selection`.

## Test plan

### After

```
./node_modules/.bin/vitest run packages/lexical/src packages/lexical-selection packages/lexical-html packages/lexical-markdown packages/lexical-clipboard packages/lexical-mdast packages/lexical-table packages/lexical-playground/__tests__/unit/MarkdownTransformers.test.ts scripts/__tests__/unit/treeShakingSource.test.ts
Test Files  212 passed (212)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@etrepum etrepum changed the title [lexical-markdown] Bug Fix: Keep format markers balanced around formatted whitespace in Markdown export [lexical][lexical-markdown][lexical-selection] Bug Fix: Keep Markdown export's format markers balanced around formatted whitespace, in linear time Oct 29, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@etrepum
etrepum added this pull request to the merge queue Oct 29, 2026
Merged via the queue into meta:main with commit e28f4dd Oct 29, 2026
53 checks passed

This branch was successfully deployed

2 active deployments
Preview – lexical — b2a3fe5b Deployed Oct 29, 2026 by vercel[bot]
Preview – lexical-playground — b2a3fe5b Deployed Oct 29, 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: Markdown export drops a closing ** or * when a formatted space comes before a link

3 participants