fixing two bugs stacked on top of each other in ProseMirror

Published: (March 28, 2026 at 08:38 PM EDT)
4 min read
Source: Dev.to

Source: Dev.to

Problem Description

I’m building a markdown editor on top of Milkdown (which wraps ProseMirror).
When I type a bold heading, move the cursor to the start of it, and press Backspace, the heading joins into the paragraph above, but the bold marks bleed into the previously unstyled paragraph text.

Why Marks Survive the Join

Pressing Backspace at position 0 of a textblock triggers joinTextblockBackward. Internally, joinTextblocksAround calls:

replaceStep(state.doc, beforePos, afterPos, Slice.empty)

This deletes the boundary between the two blocks. The Fitter algorithm stitches the content of the second block into the first without touching inline marks—the text nodes are transferred as‑is, bold included.

There is a clearIncompatible function that strips marks disallowed by the target node type, but paragraphs allow bold, so nothing is stripped. splitBlockKeepMarks handles the reverse operation (Enter) and deals with storedMarks (cursor behavior for the next typed character), which is a different problem.

Thus, marks survive the join, and they’re supposed to. Consider two paragraphs:

She said the results were
**statistically significant** and could not be ignored.

Pressing Backspace at the start of the second paragraph merges them, and the bold must survive because the user explicitly applied it. If ProseMirror stripped marks on join, it would destroy user content.

The heading case feels wrong for a different reason. In markdown, # **Bold Heading** has explicit bold marks on the text nodes because that’s what the source says. You see the boldness as a visual property of the heading, but ProseMirror treats it as an inline mark inside a heading node. When the heading unwraps into a paragraph, only the node type changes; the marks stay because ProseMirror doesn’t know they represented “heading‑ness” rather than an intentional bold style.

Conclusion: This isn’t a ProseMirror bug; it’s a modeling problem specific to markdown editors.

Solution: Intercept joinTextblockBackward

The fix intercepts joinTextblockBackward’s dispatch, snapshots the heading content range, and then removes the marks that belonged to the former heading. It also clears storedMarks so the cursor doesn’t inherit them. All changes happen in a single transaction, so undo (Ctrl+Z) reverses everything atomically.

Stand‑alone Plugin

import { Plugin, PluginKey } from "prosemirror-state";
import { joinTextblockBackward } from "prosemirror-commands";

const headingBackspacePlugin = new Plugin({
  key: new PluginKey("heading-backspace"),
  props: {
    handleKeyDown(view, event) {
      if (event.key !== "Backspace") return false;

      const { state, dispatch } = view;
      const headingType = state.schema.nodes.heading;
      if (!headingType) return false;

      const { $from, empty } = state.selection;
      if (
        !empty ||
        $from.parentOffset !== 0 ||
        $from.node().type !== headingType
      )
        return false;

      // Snapshot before the join mutates anything.
      const headingContentSize = $from.node().content.size;
      const prevEnd = $from.before();

      const wrappedDispatch = (tr) => {
        // Map prevEnd through the join's ReplaceStep to find where
        // the heading content now sits inside the merged paragraph.
        const from = tr.mapping.map(prevEnd);
        const to = from + headingContentSize;

        // Strip every mark type from the former‑heading range.
        for (const markType of Object.values(state.schema.marks)) {
          tr.removeMark(from, to, markType);
        }

        // Clear storedMarks so the cursor doesn't inherit marks.
        tr.setStoredMarks([]);

        dispatch(tr);
      };

      return joinTextblockBackward(state, wrappedDispatch, view);
    },
  },
});

How it works

  • $from.before() gives the position just before the heading’s opening token.
  • After the join’s ReplaceStep deletes the block boundary, tr.mapping.map(prevEnd) yields the new position where the heading content starts inside the merged paragraph.
  • headingContentSize remains stable because the join doesn’t alter the heading’s content, only its location.
  • tr.removeMark can be called multiple times on the same transaction; it safely removes each mark type from the specified range.
  • tr.setStoredMarks([]) clears cursor marks so typing after the join doesn’t inherit bold/italic.

The whole operation is a single transaction, so undo restores both the join and the mark removal together.

Integration

If you’re using Milkdown or Tiptap, register this plugin through the framework’s plugin API. The underlying ProseMirror logic is identical.

0 views
Back to Blog

Related posts

Read more »

NetNostalgia

Overview I’ve always been fascinated by how fast the internet evolved. From messy, colorful websites in the 90s to the clean, minimal design we have today — it...