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
maxDepthon any node type behave identically to today — no change to content-matching paths, no extra allocations. - Fully backward compatible.
maxDepthis 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, andNode.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.
maxDepthis 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