Skip to content

[web] setLink corrupts multi-block selections; onChangeSelection text isn't round-trip safe #757

Description

@henrikra

Summary

On web, the text emitted by onChangeSelection is not round-trip compatible with setLink, because the two use different serializations of the same range. Passing the event's own text back into setLink (the documented way to link the current selection) corrupts any multi-block or image-containing selection — it merges the blocks into one paragraph and drops inline formatting. Separately, setLink silently no-ops when text is empty.

Verified on 1.1.0, web.

The inconsistency

onChangeSelection serializes the selected text with nativeLeafText, which inserts a \n at block boundaries and for inline leaves:

// src/web/EnrichedTextInput.tsx:276
const text = nativeLeafText(state.doc, from, to);
// src/web/positionMapping.ts:29
return doc.textBetween(from, to, '\n', () => '');

But setLink compares the caller's text against a different serialization — plain doc.textBetween(from, to) with no block separator and no leaf placeholder:

// src/web/formats/EnrichedLink.ts:165
const currentText = doc.textBetween(from, to);
if (text !== currentText) {
  tr.replaceWith(from, to, s.schema.text(text, marksWithLink)); // :170  destructive
} else {
  tr.addMark(from, to, linkMark);                               // :172  intended
}

For any selection spanning a block boundary (or containing an inline image), text contains \n/ while currentText does not, so text !== currentText and it takes the replaceWith branch. Result: the selected blocks collapse into a single paragraph, the \n/ become literal glyphs in the link text, and inline marks inside the range are lost.

Reproduction (public API only)

Two paragraphs First paragraph / Second paragraph. Select across the boundary, then apply the link using the text from onChangeSelection:

// sel = the {start, end, text} from the last onChangeSelection event
// -> { start: 15, end: 32, text: "\nSecond paragraph" }
ref.current.setLink(sel.start, sel.end, sel.text, "https://example.com");

Expected: the link is applied across the selection (or a safe no-op).
Actual:

Before: <p>First paragraph</p><p>Second paragraph</p>
After:  <p>First paragraph<a href="https://example.com">\nSecond paragraph</a></p>

The two paragraphs are merged and the \n is rendered as a literal glyph inside the anchor.

Secondary: empty text is a silent no-op

// src/web/formats/EnrichedLink.ts:141
if (url.length === 0 || text.length === 0) {
  return;
}

setLink(start, end, "", url) on a valid selection does nothing, with no return value or warning. Since "link the current selection without changing its text" is the most natural call, this is easy to hit and hard to diagnose. Confirmed on 1.1.0: setLink(6, 11, "", url) over a selected word leaves the document unchanged; setLink(6, 11, "word", url) works.

Suggested fix

Compute currentText in setLink with the same nativeLeafText serialization used by onChangeSelection (so a faithful round-trip hits the non-destructive addMark branch), and/or refuse a range whose text disagrees with the mapped content instead of silently flattening it. For the empty-text case, treating it as "apply the link mark to the existing selection" (or returning a boolean) would remove a sharp edge.

Environment

  • react-native-enriched-html 1.1.0 (same code in 1.0.0)
  • Platform: web

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions