Feature request: maxDepth on NodeSpec — Upstream ProseMirror Pitch

What this adds

A single optional field on NodeSpec:

interface NodeSpec {
  // ...existing fields...

  /**
   * The maximum number of times this node type may appear in its own
   * ancestor chain. When set, ProseMirror will prevent the node from
   * being inserted, wrapped, or pasted into a position where the ancestor
   * chain already contains `maxDepth` instances of this type.
   *
   * Unset (the default) means unbounded — existing behaviour is unchanged.
   */
  maxDepth?: number
}

And a fast-path flag on Schema so schemas that don’t use it pay no runtime cost:

class Schema {
  // true if any node type in this schema has maxDepth set
  readonly hasMaxDepth: boolean
}

The problem it solves

ProseMirror content expressions describe which types can appear as children of a node, but have no way to express how many times a type may recur in its own ancestor chain. This means schemas that want to allow a container node to nest inside itself — but only to a bounded depth — currently have no schema-level mechanism to express or enforce that constraint.

Why limit depth at all?

Mature editors that aim to build accessible, performant, user friendly, and collaborative experiences need to create a ‘walled garden’ to some extent. ProseMirror has a beautiful established pattern to handle recursive nesting without issue but the core issue with this is the user experience can become overwhelming.

The only workarounds available today involve encoding depth information outside the schema:

Option A — Multiply node types. Define panel, panel_d1, panel_d2 as separate node types with progressively restricted content expressions. This works and keeps enforcement in the schema, but it multiplies the number of node types in proportion to the number of container nodes and depth levels, complicates serialisation (depth-stamped types must be stripped on export and re-resolved on import), and requires every consumer (menus, NodeViews, renderers, transformers) to handle each variant explicitly.

Option B — Plugin-level transaction enforcement. Use filterTransaction or appendTransaction to reject or fix up transactions that would violate the depth limit. This is fundamentally leaky: it operates after the fact, doesn’t integrate with findWrapping (so menus and slash commands can suggest insertions that then fail), doesn’t propagate through collaborative editing step remapping, and creates a mismatch between what the schema says is valid and what the editor actually permits.

Neither option is satisfying. Both push complexity onto consumers that the schema layer is better placed to handle. Please let me know if there are other options or design patterns in case I missed something obvious.


Design goals

  • Zero cost when unused. Schemas that don’t declare maxDepth on any node type behave identically to today — no change to content-matching paths, no extra allocations.
  • Fully backward compatible. maxDepth is optional. All existing schemas, content expressions, NFA/DFA compilation, and snapshot tests are unchanged.
  • Enforced everywhere the schema is enforced. The constraint propagates through canReplace, canReplaceWith, findWrapping, and Node.check() — the same call sites that enforce existing content rules. Paste, drag-and-drop, collaborative step application, and UI affordances all respect it automatically.
  • No grammar changes. The content expression syntax, parser, NFA compiler, and DFA are untouched. maxDepth is a separate runtime bound checked at match time, not a grammar-level construct.

Technical explanation removed, see pull request for detail: #93 - feat: add maxDepth to NodeSpec for bounded recursive nesting - prosemirror/prosemirror-model - code.haverbeke.berlin

Please don’t post AI slop here. If you can’t be bothered to write it, I can’t be bothered to read it.

1 Like

Hi,

I spent a long time on this, it’s well structured, coherent, and genuinely a very reasonable request given the purpose of the dependancy.

I specifically called out where AI was used and noted that was for technical detail and should be dismissed if opposed. (Edit: now removed & replaced with PR)

I do hope you can look past some em dashes and help address the core problem I’m trying to solve or at least provide some guidance on alternative approaches.

It’s not about emdashes. I use emdashes all the time. It’s about the general tone and structure of the text. Say, phrasing like this:

In any case, the content you now removed stated you used AI. This project will not accept AI code contributions, and I don’t have patience for AI text.

I’ve stated my technical objections to the proposed feature in my comment on the PR.

1 Like

Hilariously that is word for word straight from my human brain. I don’t know what to tell you :person_shrugging:

Edit: That is literally the core reasoning for this request put as concisely as possible.

Also stated initially in the disclaimer (because I figured someone like yourself would have issue with AI) I do not have any opinions about the technical implementation it’s just the core api I want implemented.

Edit 2: I even called your architecture ‘beautiful’ to obtain some brownie points as a new poster lol

In that case, I apologize for the immediate dismissal. The way slop is popping up everywhere is making me very suspicious of anything that sounds like it, and unfortunately that can lead to false positives.

I would also probably have been a little more patient if you had stated your affiliation up front. (That being said, I very much stand by my conclusion on the proposed feature. If you want to discuss the shape of a workaround for your use case, I can chime in on that.)

All good, I completely understand; it’s a bad time to be managing a forum at the moment.

I’ll see how I go with it all given the constraints over the following days, figured it couldn’t hurt to try see if I could get something like this over the line.

Thanks for the insight regardless.