Repository navigation
[lexical][lexical-markdown][lexical-selection] Bug Fix: Keep Markdown export's format markers balanced around formatted whitespace, in linear time - #9420
Merged
Conversation
…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`.
fixfon
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
October 21, 2026 20:04
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
…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>
## 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>
…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>
… 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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
etrepum
approved these changes
Oct 29, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
**_a_ [b](u)**exported as***a* [**b**](u);a, bold italic, boldcexported as**a **c****;a, bold italic, bold strikethrough, plaincexported as**a ** c;$convertSelectionToMarkdownStringread siblings' whole text past the end of the selection, leaving tags unclosed: bolda, bold italicbc, boldd, cut afterb, 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. OnecanHoldTag(text)decides whether text can carry a tag for both the exported node and the siblings it looks at, replacingcheckHasFormat. 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** cand**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.isSelecteduses a cached key set when asked repeatedly about a selection whose nodes are cached.$sliceSelectedTextNodeContentreturns early for a node that no text point is in, which also helps HTML and clipboard export. The whitespace split around tags usestrimStart/trimEndinstead of a regex that backtracks over long runs of spaces.Closes #9403
Changeset
.changeset/markdown-formatted-whitespace-export.md:[fix]for@lexical/markdown,lexicaland@lexical/selection, with aVerify:paragraph for Markdown export of text where a space carries different formatting than the words around it.Test plan
Before
After
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).