Skip to content

fix: don't double-map positions in the visual editor's sync-heading-links handler - #1228

Open
kevinushey wants to merge 2 commits into
quarto-dev:mainfrom
kevinushey:bugfix/heading-links-mapping
Open

kevinushey wants to merge 2 commits into
quarto-dev:mainfrom
kevinushey:bugfix/heading-links-mapping

Conversation

@kevinushey

Copy link
Copy Markdown
Contributor

In the visual editor, switching a document from source to visual fails with RangeError: Position N out of range when the document contains an image wrapped in a link ([![alt](img.png)](https://...)) and ends with a link. Reported in rstudio/rstudio#19588; the reporter's attachment reproduces it on every switch after the first.

Cause

syncHeadingLinksAppendTransaction() (packages/editor/src/marks/link/link-headings.ts) scans link and heading positions from tr.doc and then maps them through tr.mapping. But tr.doc already reflects every step on tr, so any step taken before the scan is applied to those positions twice.

The append handlers share one transaction, and figure-convert (registered by nodeFigure, ahead of markLink) runs first. When setMarkdown reloads the document, it turns each linked image back into a figure, which lengthens the document before sync-heading-links scans it. Every link position then lands too far along. In the middle of the document that is silently harmless, since the handler only acts on links bound to a heading. A link near the end is pushed past the end of the document, and tr.doc.resolve() throws.

Stack from the repro: appendTransaction -> sync-heading-links append (link-headings.ts:68) -> Node.resolve.

The inner loop that rewrites links after a heading is renamed had the same problem within the handler itself: when two linked headings are renamed in one transaction, the link scan for the second heading also maps through the text inserted for the first.

Fix

Each scan records tr.mapping.maps.length beforehand, and its positions are mapped through tr.mapping.slice(start), i.e. only through the steps added after that scan. This is the same guarantee the other append handlers get from trTransform(), which runs them against a fresh Transform.

Tests

packages/editor/test/link-headings.test.ts drives the handler directly against a minimal schema:

  • a link at the end of the document, with a step from an earlier handler already on the transaction;
  • a heading link whose heading attribute is out of date, with the same earlier step;
  • two renamed headings whose links are rewritten in one pass, the second shifted by the first.

All three fail before the change: the first two throw RangeError: Position N out of range, and the third leaves the second link's text and heading attribute stale. They pass after it, along with the rest of the editor suite.

Verification

With panmirror built from this branch and served by an RStudio dev server, the reporter's document (CRLF and LF copies) switches source -> visual -> source -> visual twice with no error. With the panmirror currently pinned by RStudio, it fails on the first switch back to visual.

The sync-heading-links append handler scanned link and heading positions
from tr.doc, which already reflects every step on the transaction, then
mapped them through tr.mapping again. When an earlier append handler in the
same pass had changed the document (figure-convert turning a linked image
into a figure), each position was shifted twice, and a link near the end of
the document resolved past it: "RangeError: Position N out of range" when
switching RStudio from Source to Visual (rstudio/rstudio#19588).
@posit-snyk-bot

posit-snyk-bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants