Skip to content

[lexical-mdast] Bug Fix: Keep edits to imported autolink literals - #9669

Open
clean99 wants to merge 4 commits into
meta:mainfrom
clean99:contrib/lexical-mdast-edited-autolinks
Open

clean99 wants to merge 4 commits into
meta:mainfrom
clean99:contrib/lexical-mdast-edited-autolinks

Conversation

@clean99

@clean99 clean99 commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Description

After importing a bare URL, changing its destination, label or formatting is lost on Markdown export. The saved literal style currently emits only the visible text.

Keep the bare form only when its plain text still parses as the same link. Otherwise use the default link serializer. Cache the labels used by the latest whole-document export; subtree and selection exports only add to that cache. Unchanged literals keep their syntax; neighbouring-text ambiguity remains unchanged.

Changeset

.changeset/mdast-edited-autolink-literals.md

Test plan

pnpm run test-unit packages/lexical-mdast --maxWorkers=1

Before

The added round-trip test with the original exporter:

Expected: "[https://one.example](https://changed.example) [caption](https://two.example) [**https://three.example**](https://three.example)"
Received: "https://one.example caption https://three.example"

After

Test Files  8 passed (8)
     Tests  289 passed (289)

The tests cover edited-link round trips and parser calls during lookahead, repeated, subtree and selection exports, including a 1,001-link document whose second export reuses every label. pnpm run ci-check passes. Browser suites were not run; these are editor-state and serialization tests in the package's Node unit tests.

## Description

After importing a bare URL, changing its destination, label or formatting is lost on Markdown export. The saved literal style currently emits only the visible text.

Keep the bare form only when its plain text still parses as the same link. Otherwise use the default link serializer. Unchanged literals keep their syntax.

## Changeset

`.changeset/mdast-edited-autolink-literals.md`

## Test plan

`pnpm run test-unit packages/lexical-mdast --maxWorkers=1`

### Before

The added round-trip test with the original exporter:

```text
Expected: "[https://one.example](https://changed.example) [caption](https://two.example) [**https://three.example**](https://three.example)"
Received: "https://one.example caption https://three.example"
```

### After

```text
Test Files  8 passed (8)
     Tests  286 passed (286)
```

The regression reimports the export and checks destinations, labels, bold formatting and a second export. Browser suites were not run; this exercises editor state and serialization in the package's Node unit tests.
## Description

After importing a bare URL, changing its destination, label or formatting is lost on Markdown export. The saved literal style currently emits only the visible text.

Keep the bare form only when its plain text still parses as the same link. Otherwise use the default link serializer. Reuse parsed destinations during lookahead and repeated exports, retaining only the literal labels seen in the latest export. Unchanged literals keep their syntax.

## Changeset

`.changeset/mdast-edited-autolink-literals.md`

## Test plan

`pnpm run test-unit packages/lexical-mdast --maxWorkers=1`

### Before

The added round-trip test with the original exporter:

```text
Expected: "[https://one.example](https://changed.example) [caption](https://two.example) [**https://three.example**](https://three.example)"
Received: "https://one.example caption https://three.example"
```

### After

```text
Test Files  8 passed (8)
     Tests  287 passed (287)
```

The tests cover edited-link round trips and parser calls during lookahead and repeated exports. `pnpm run ci-check`, package builds and the full unit suite with fixture-only `GIT_CONFIG_GLOBAL=/dev/null` also pass. Browser suites were not run; this exercises editor state and serialization in the package's Node unit tests.
@vercel

vercel Bot commented Oct 31, 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:52pm UTC
lexical-playground Ready Ready Preview Oct 31, 2026 6:52pm UTC

Request Review

@clean99
clean99 requested a review from etrepum as a code owner October 31, 2026 09:30
@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 31, 2026
@meta-code-review

meta-code-review Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 7:06 PM UTC

@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 5d63219

@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, this looks good. Edited bare URLs now survive a Markdown round trip, and the new round-trip test in MdastImportExport.test.ts checks both export and re-import. Everything below is optional and non-blocking.

Optional suggestions

  1. The bare-form check doesn't look at neighbouring text (packages/lexical-mdast/src/MdastExport.ts:207, :674-700). The GFM spec's extended-autolink rule recognises a literal only at the start of a line, after whitespace, or after *, _, ~ or (, and the literal runs on over the characters that follow it. The label is parsed on its own, so deleting the space in see https://one.example x still exports seehttps://one.example x, which re-imports with no link. Typing /abc straight after the link changes the re-imported URL. Both cases behaved the same before this PR, so a follow-up is fine. The handler's info.before and info.after would let it fall back to [label](url) there. Meanwhile, the comment "only while it still parses as the same link" could mention the limitation.
  2. The validation cache keeps only the latest export (MdastExport.ts:722). Each uncached label runs a full fromMarkdown, roughly 0.4 ms. On a document with 300 distinct bare URLs the first export after import went from about 18 ms to about 139 ms. Calling $convertToMarkdownString(node) on 20 different paragraphs in turn went from about 1.8 ms to about 22 ms, because each call evicts the other entries. Repeated whole-document exports are unchanged. A label's validity doesn't depend on the export root, so a bounded map that isn't cleared on every call would avoid the repeat cost.
  3. Copy-as-Markdown of part of a bare URL now gives [https://one.ex](https://one.example) rather than https://one.ex. That is consistent with the fix, since the old output re-read as a link to the truncated URL. It is still an observable output change: a test would pin it, and AGENTS.md asks for the changeset's Verify: paragraph to describe output changes, so it could mention it.
  4. One parametrised case for edited www. and email literals, and for a literal inside a GFM table cell, would pin behaviour that already works with this change.

node.children.length === 1 &&
node.children[0].type === 'text'
) {
// Preserve the bare form only while it still parses as the same link.

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, non-blocking: this check only sees the label, so neighbouring text can still change how the bare form re-parses. GFM extended autolinks need a line start, whitespace, or */_/~/( before them, and they run on over the characters that follow. For example, deleting the space in see https://one.example x exports seehttps://one.example x, which re-imports without a link, the same as before this PR. A follow-up could use info.before/info.after to fall back to [label](url) in those cases; until then, this comment could note the limitation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a note on the neighbouring-text limitation in that comment; the behavior there is unchanged in this PR.

syntaxToMarkdown(getLiteralUrl, collectHardBreaks(tree)),
],
});
literalUrls = nextLiteralUrls;

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, non-blocking: replacing the cache on every call means that alternating $convertToMarkdownString(node) calls on different subtrees, or a selection export between document exports, re-parse every literal each time (roughly 0.4 ms per label). A label's validity doesn't depend on the export root, so a bounded map that isn't cleared on every call would keep the benefit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The cache now keeps up to 1,000 labels per exporter (oldest evicted) instead of being replaced on every call, with a regression for alternating subtree and selection exports.

## Description

Alternating subtree exports or a selection export between document exports
replaces cached literal results and repeats parsing. Keep up to 1,000 labels
per exporter, evicting the oldest instead of replacing the map after each
export. Note the existing neighbouring-text limitation without changing
literal serialization rules.

## Test plan

### Before

`pnpm run test-unit packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts --maxWorkers=1`
with the new subtree/selection test and the previous implementation:

```text
AssertionError: expected 5 to be 2 // Object.is equality
Test Files  1 failed (1)
     Tests  1 failed | 106 passed (107)
```

### After

`pnpm run test-unit packages/lexical-mdast --maxWorkers=1`

```text
Test Files  8 passed (8)
     Tests  288 passed (288)
```

`pnpm run ci-check`, development/production/www builds and declaration
validation pass. The full unit suite with fixture-only
`GIT_CONFIG_GLOBAL=/dev/null` passes: 31,522 passed, 470 expected fail,
135 skipped. Browser suites were not rerun locally.

@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, this looks good. Re-parsing the literal's label with the editor's own grammar and falling back to [label](url) is a clean fix: edited destinations, labels and formatting now survive a round trip, unchanged bare URLs keep their spelling, and an edited label is escaped instead of being written raw, so typed [x](...) or <b> no longer comes back as live syntax. The new tests fail on main with exactly the loss the description shows and pass here.

A few optional, non-blocking suggestions:

  1. Cache eviction (packages/lexical-mdast/src/MdastExport.ts, lines 693-696). The 1,000-entry cap evicts the oldest insert while lookups walk the document in order. Once a document has more than 1,000 distinct literal labels every lookup misses, so every export re-parses all of them (roughly 10x the main export time at 1,500 links in a local Node run). Below the cap, each run of newly typed labels eventually evicts the oldest document label and that export re-parses the whole document. Keeping the labels the latest whole-document export used (as the earlier revision of this PR did) and letting subtree and selection exports only add would bound memory by the document without either cliff.
  2. Text typed right after a literal (pre-existing, already noted in the comment at line 209). see https://a.example followed by a typed x still exports see https://a.examplex, which GFM reads as a single link ("After a valid domain, zero or more non-space non-< characters may follow"). The handler's info.after could gate the bare form; this is fine as a follow-up.
  3. PR description. The Test plan's After block (287 tests) and the sentence about retaining only the labels from the latest export describe the previous commit; the suite now has 288 tests and the cache is shared across exports. AGENTS.md (Commits and Pull Requests) asks the Test plan to carry the real output of the command that was run.

Comment on lines +693 to +696
literalUrls.set(text, url);
if (literalUrls.size > 1000) {
literalUrls.delete(literalUrls.keys().next().value!);
}

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, non-blocking: lookups walk the document in order while this evicts the oldest insert, so once a document has more than 1,000 distinct literal labels every lookup misses and every export re-parses all of them (about 512 ms against 51 ms on main at 1,500 links in a local Node run). Below the cap, enough newly typed labels evict the oldest document label and that export re-parses everything again. Keeping the labels the latest whole-document export used, and letting subtree and selection exports only add, would avoid both.

@clean99 clean99 Oct 31, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done: a whole-document export now replaces the remembered labels with the ones it used, and subtree and selection exports only add to them, so there is no eviction. A new test checks that a second export of a 1,001-link document parses nothing again (it re-parsed all 1,001 labels before). A warm export of 1,500 bare URLs went from about 190 ms on the previous commit to about 6 ms, the same as main.

) {
// Preserve the bare form only while it still parses as the same link.
// Edits to its destination, label or formatting need resource syntax.
// Neighbouring text can still change how the bare form re-parses.

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 follow-up: this case is reachable by typing. see https://a.example plus a typed x (which lands outside the link) exports see https://a.examplex and re-imports as a link to https://a.examplex, since GFM lets any non-space non-< characters follow a valid domain. Checking info.after here before returning the bare text would close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I left this one out. In the link handler info.after holds only the first character of what follows, so a check cannot tell a sentence-ending . (which GFM leaves outside the autolink) from .x. Guarding on any following non-space character would turn every imported https://a.example. into [https://a.example](https://a.example). on an unchanged round trip. Fixing it properly needs the full serialized suffix, so it fits better as a separate change.

@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, this looks good. Re-parsing the literal's label with the editor's own grammar and falling back to [label](url) fixes the lost edits cleanly: changed destinations, labels and formatting now survive a round trip, unchanged bare URLs, www. addresses and emails keep their spelling, and an edited label is escaped rather than written raw, so text like [x](y) typed into a label no longer comes back as a live link. The cache change in the latest commit makes repeated exports cost the same as on main. Everything below is optional and non-blocking.

Optional suggestions

  1. First export of a link-heavy document (packages/lexical-mdast/src/MdastExport.ts:684). Every label not yet cached costs a full fromMarkdown run, about 0.35 ms each in a local Node run: the first export after import went from about 11 ms to 111 ms with 300 bare URLs, and from 60 ms to 544 ms with 1,500; second exports are unchanged. Headless one-shot conversions (import, then export once) always take that path. Skipping the parse while a literal's label and URL are still the ones it was imported with, or parsing the uncached labels of one export in a single batch, would remove most of it. A follow-up is fine.
  2. The Verify: paragraph (.changeset/mdast-edited-autolink-literals.md:7). AGENTS.md (Commits and Pull Requests) asks Verify: to say what to test when output changes, and three outputs change besides edited literals: a literal inside emphasis or strikethrough (**https://a.example**) now exports as [**https://a.example**](https://a.example) instead of dropping the formatting; an editor without MdastAutolinkLiteralExtension writes <https://a.example> for literal-styled links loaded from JSON; and copying part of a bare URL as Markdown gives [https://one.ex](https://one.example). Contributed mdastExtensions transforms also run during export now, once per uncached label, which extension authors may want to know.
  3. Tests for those three outputs in MdastImportExport.test.ts would keep them from drifting. An edited email literal is also not covered yet.
  4. Neighbouring text, already noted in the comment at MdastExport.ts:208-210 and agreed as a separate change. Your reason for leaving info.after out holds. GFM's preceding-character rule looks at exactly one character, so info.before could handle the deleted-space case without that ambiguity when you pick this up.

const getLiteralUrl = (text: string): string | null => {
let url = literalUrls.get(text);
if (url === undefined) {
const parsed = fromMarkdown(text, {

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, non-blocking: each label not yet cached costs a full fromMarkdown run here, about 0.35 ms in a local Node run. The first export after import went from about 11 ms to 111 ms with 300 bare URLs, and from 60 ms to 544 ms with 1,500; repeated exports match main. Headless one-shot conversions always pay it. Skipping the parse while the label and URL are still the imported ones, or batching the uncached labels of one export into a single parse, would remove most of it. Fine as a follow-up.


[fix] Preserve edits to imported autolink literals when exporting Markdown

Verify: Import a bare URL with `MdastAutolinkLiteralExtension`, change its destination, label or text formatting, then export and reimport it. The edits should survive. Unchanged bare URLs and email addresses should keep their original syntax.

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, non-blocking: AGENTS.md asks Verify: to say what to test when output changes. Besides edited literals, three outputs change: **https://a.example** now exports as [**https://a.example**](https://a.example) (the formatting used to be dropped), an editor without MdastAutolinkLiteralExtension writes <https://a.example> for literal-styled links loaded from JSON, and copying part of a bare URL gives [https://one.ex](https://one.example). One sentence naming them would tell upgraders where to look.

This branch was successfully deployed

2 active deployments
Preview – lexical — 5d632195 Deployed Oct 31, 2026 by vercel[bot]
Preview – lexical-playground — 5d632195 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant