Is it expected that a ReplaceStep widens past an inline atom node, deleting it?

I have inline atom nodes that act as field markers (fieldStart / fieldEnd) wrapping ordinary editable text inside a paragraph. I’m seeing a replace (typing over a selection) delete these atom nodes even though my selection ended before them, and I’d like to confirm whether this is correct/expected behavior.

Setup

Two inline atom nodes (simplified):

const fieldStart = {
  group: "inline", inline: true, atom: true, selectable: false,
  attrs: { guid: {} },
  // toDOM / parseDOM ...
};
const fieldEnd = { /* same shape */ };

Document (positions shown):

<p>
  <span font-size:14pt>PROCEDURE:</span>   // styled run
  <span font-size:10pt>   </span>
  fieldStart@16
  "one two"                                // editable field content
  fieldEnd@24
</p>

Reproduction

  1. Selection from pos 1 (inside the font-size:14pt span) to pos 20 (in the middle of one two).
  2. Type a single character x to replace the selection. What I observe

The selection is [1 → 20], but the actual step is:

[
  { "text": "x",    "marks": [{ "type": "style", "attrs": { "style": "font-size: 18.6667px;" } }] },
  { "text": " two", "marks": [{ "type": "style", "attrs": { "style": "font-size: 10pt;" } }] }
]

So the inserted x took the start mark (14pt), the trailing " two" was preserved, but the two atom nodes in the interior were dropped.

My questions

  1. Is the widening of step.to from 20 to 24 expected here?

  2. Is it expected that inline atom nodes sitting in the interior of the widened range are simply discarded (not preserved like the open trailing content)?

  3. Is there a supported way to make these marker atoms resist being crossed/consumed by such a widened replace, short of redesigning them into a single isolating wrapper node with content? (isolating on the inline atoms themselves didn’t help.)

    Versions: prosemirror-view 1.28.2, prosemirror-transform 1.7.0, prosemirror-state/model current.

Thanks!

Have you verified that the parse rules for those custom nodes work? If not, reading them back out from modified browser DOM content might fail.

Thanks Marijn.

The parseDOM/toDOM round-trip works correctly in isolation — I can serialize and re-parse the document and the atoms survive.

I’ve put together a minimal reproduction that strips away the atom nodes entirely and shows the same class of problem with just inline decorations and mark boundaries:

Steps: Select from “custom-mark:” across into the yellow-highlighted text, then type to replace. The decoration is permanently lost via DecorationSet.map().

It seems like the mark boundary at the decoration start position is causing the mapping to collapse the decoration range to zero, which triggers removal. Would you expect map() to preserve the decoration in this case (since the replacement doesn’t fully cover it)?

Recording 2026-06-16 192550

After the text Change, Chrome (but not Firefox) turns the DOM into this:

<p style="text-align: left; font-family: Arial;">
  <custom-mark style="color: blue; font-family: Arial;">
    <span style="background-color: yellow;">
      <span style="font-size: 18.6667px;">f</span>
      <span style="font-size: 10pt;">ne t</span>
    </span>
    <span style="font-size: 10pt;">wo</span>
  </custom-mark>
</p>

By your parse rules, that is parsed as having both the custom-mark and the style attributes. So, since the content no longer matches, the editor creates a replace step over that entire bit of text, which clears your decoration.

The best way to avoid this is probably to not have this generic style mark, but define specific marks for the styles you want to support. That way, your document has a single canonical representation, rather than being at the mercy of whatever messy HTML you’re parsing.