ProseMirror appendTransaction: normalize content without a transaction loop

Use a plugin's appendTransaction hook when an applied update needs a follow-up document transformation. Inspect newState, construct a fresh transaction from that state, and return it only when the document needs a change. Once the normalization rule is satisfied, return null. The hook can run again after another plugin appends a transaction, so returning a transaction unconditionally can keep the update cycle alive. Plugin hook reference

An original appointment-note editor uses single-line headings. Pasting a heading containing a line break should replace that break with a space, while preserving ordinary paragraph line breaks and the heading's other attributes. This is a structural editing rule; making a reducer update a label would leave the document unchanged.

Normalize the supported heading nodes

The following plugin targets a schema with heading, text, and hard_break node types, such as the basic ProseMirror schema. Add it to the existing state's plugins. It replaces only hard-break children directly inside headings and leaves other nodes alone.

import { Plugin, PluginKey } from 'prosemirror-state';

const headingLineKey = new PluginKey('singleLineHeadings');

export const singleLineHeadings = new Plugin({
  key: headingLineKey,
  appendTransaction(transactions, oldState, newState) {
    if (!transactions.some(tr => tr.docChanged)) return null;

    const replacements = [];
    newState.doc.descendants((node, pos) => {
      if (node.type.name !== 'heading') return;
      node.forEach((child, offset) => {
        if (child.type.name === 'hard_break') {
          replacements.push({
            from: pos + 1 + offset,
            to: pos + 1 + offset + child.nodeSize
          });
        }
      });
      return false;
    });
    if (!replacements.length) return null;

    const tr = newState.tr;
    for (const replacement of replacements.reverse()) {
      tr.replaceWith(
        replacement.from,
        replacement.to,
        newState.schema.text(' ')
      );
    }
    return tr.setMeta(headingLineKey, 'normalized');
  }
});

The hook receives transactions that led to the new state, plus the states before and after that update. It should return a transaction rather than dispatching through an editor view from inside the hook. The official plugin source contract explains subsequent calls when other plugins append changes.

This rule assumes the schema permits a space in a heading. It does not collapse multiple spaces or rewrite pasted paragraphs. The heading node itself is retained, so its level and other attributes remain in place.

Measure positions in the state being changed

The descendant position points before the heading. Adding one reaches its content, and the child offset identifies the break within that content. The code gathers positions from newState.doc, then replaces them from right to left. An earlier replacement therefore cannot move an unprocessed position to its right.

For this fixture, a hard break and a single text space each have size one. Right-to-left replacement also makes the intent clear if the replacement policy later changes length. If an operation instead builds left-to-right steps using original positions, map those positions through the transaction. The transaction mapping guide demonstrates that separate technique.

Make the rule converge on its result

After the replacement, the heading contains a text space rather than a hard break. A subsequent pass finds no matching children and returns null. The metadata names this plugin's operation for debugging; the absence of further work comes from inspecting the resulting content.

Do not rely only on a metadata flag to prevent a loop. Another plugin can append a transaction without that flag, and the hooks may be called again with transactions they have not yet seen. Two plugins that repeatedly undo each other's rules need compatible policies, not increasingly elaborate skip flags.

The initial docChanged check avoids rescanning on a cursor-only update. It also means this plugin does not repair the initial document merely because a state was created. If loaded records must already satisfy the rule, validate or normalize them through the application's deliberate load path before exposing them as accepted content.

Check history and the complete update path

If the application implements dispatchTransaction, apply updates with state.applyTransaction(tr) and update the view from the returned state so appended transactions are included. Inspect the returned transaction list when debugging whether normalization ran. A handler that bypasses the state's normal transaction pipeline can produce a different result from the expected plugin behavior.

In a development editor, paste a heading with a hard break and confirm it becomes a space. Paste the same structure into a paragraph and confirm the break remains. Move the cursor and confirm no extra document transformation is added. Test Undo and Redo with the configured history rather than assigning an exclusion flag by habit; authors should understand whether the paste and its normalization return together.

The plugin-state guide covers values derived alongside the document. This normalization changes the document itself. Save the resulting note through its normal persistence route, reopen it, and read the single-line heading in context before treating the rule as complete.

Keep reading