Skip to content

0037. The editor and the server agree on the marks one run may carry

Status: Accepted

Date: 2026-10-31

Context

A run of text carries its styles as ProseMirror marks in the editor, and a ProseMirror mark type declares which other marks it excludes from the same run. BlockNote's code mark excludes every mark that is not in the annotation group, the link's included: at 0.57.0 its excludes() returns the names of every such mark the editor holds (src/schema/markGroups.ts in BlockNote's repository), and at 0.54.2 it already declared tiptap's excludes: "_", every mark. BlockNote's link mark declares no excludes, so it excludes only itself. The module's own subscript and superscript exclude each other whenever a format permits both (0031).

BlockNote 0.57.0 added a check of the document to the BlockNoteEditor constructor: the document built from initialContent is checked against the schema before the editor exists, and a text node whose marks exclude each other throws "Invalid collection of marks", which the constructor reports as "Error creating document from blocks passed as initialContent". 0.54.2 had no such check, so a run carrying excluded marks loaded. Measured in vitest against the module's own createEditor() on 0.57.0, {code, bold}, {code, textColor: 'red'} and a link holding a {code} run each threw, while bold inside a link opened. js/src/index.tsx then falls back to reportUnreadableDocument(), which shows the stored JSON as "not a valid BlockNote document". The upgrade to 0.57.0 handled subscript with superscript alone, in js/src/policy.ts, and named the pair in the stripped-content notice as something "This text format does not permit", with a hint saying a save without an edit is refused.

The server accepted every one of these runs. DocumentValidator checked each style key on its own and nothing about combinations, 0031 said in so many words that the server enforces nothing about exclusivity, and docs/rendering.md section 4 gave <code><strong>…</strong></code> as the output for a run carrying all seven styles. So the editor and the server disagreed: a document the server stored could not be opened, and the notice's hint described a refusal that never happened. Inline code inside a link is common in technical content, and a migration that converts such content would produce it.

What upstream does with the same combinations when it parses HTML, which is what a paste and an HTML import go through, was measured too: editor.tryParseHTMLToBlocks() reads <a href="…"><code>fn()</code></a> as a run with {code: true} and no link, and both <code><strong>x</strong></code> and <strong><code>x</code></strong> as {code: true} alone. ProseMirror's Mark.addToSet() keeps whichever of two marks excludes the other, and code excludes everything.

Decision

A save refuses a run carrying marks the editor's schema cannot hold together. DocumentValidator::checkIncompatibleStyles() refuses a run on which code meets any other style, any color or the new-tab flag, or which sits inside a link, and a run carrying both subscript and superscript. Only what the editor would hold counts: a style, a color or a new-tab flag the format forbids is refused on its own and never reaches the editor's schema, and neither does a link the format refuses, so neither is counted again here. A flag set to false and an empty color are no mark at all, and default is a mark like any other color name, because BlockNote makes a mark of any non-empty value; a format permitting no color of a kind leaves that style out of the schema, so its default is not counted either. The refusal is the new DocumentError::IncompatibleStyles, incompatible_styles, with the message "Text in the @type block carries both @style and @other, which the editor cannot show together. Remove one of the two.", worded as 0023 asks: what is wrong, then what to do. Amended 2026-10-31. @style and @other name each mark in the editor's words, "Code", "Red text" or "a link", where they had carried stored names and untranslated English; 0023's second pass records it. Amended 2026-10-31. The message is now "Text in the @type block carries two kinds of formatting the editor cannot show together: @style and @other. Remove one of the two.", with the two names after a colon, and the default color, which the editor shows as no color, is named "a text color" or "a background color"; 0023 records why. @other names the first mark code met, the link before any style. A run carrying code, subscript and superscript is refused twice, once for each pair. DocumentNormalizer does nothing about any of this: nothing on the server rewrites what an author or a migration submitted.

The editor loads such a run without code, or without subscript, and names it. Before the editor is created, js/src/policy.ts's walk applies the format's policy and then, in separateExcludedMarks(), takes off the marks the schema cannot hold together. The link around a run is never taken off. The run's other marks are tried in the run's own order, then subscript, then code, and each is kept only when nothing already kept excludes it: code gives way to everything it meets, since a link's target, a color and another style carry more than the code formatting does, and subscript gives way to superscript, as it did on load since the upgrade. What was taken off is named <taken off>:<kept>, such as code:link or subscript:superscript, in a list of its own, apart from what the format forbids.

Which marks exclude which is read from the schema, not listed. js/src/portable/exclusions.ts's markExclusions() takes the tiptap mark of every style spec the editor is about to be built with, after the alters have run, adds a mark standing in for BlockNote's link mark, and builds them into a ProseMirror schema with tiptap's own getSchema(), then asks each pair MarkType.excludes() in both directions. The editor's own schema is built inside its constructor, after the check that throws, so it cannot be asked first. Built without an editor, code's excludes() answers _, every mark, which is what it answers in an editor that holds no annotation mark, and this module registers none. js/src/__tests__/exclusions.test.ts holds the answer to the editor's own built schema for every pair, and pins the pairs, code with each of the ten other marks and subscript with superscript, to the ones checkIncompatibleStyles() refuses.

The notice says which is which, and its hint is true for both. js/src/stripped.tsx keeps "This text format does not permit: @names" for what the format forbids and adds a second label, "The editor cannot show these together, so it leaves off the first: @names", naming code with other formatting, code inside a link and subscript with superscript, two pairs that read the same named once. The data attribute is data-blocknote-excluded, beside data-blocknote-stripped. The hint now ends "which is refused" rather than "which this text format refuses", since a combination is refused whatever the format says. The rest of the notice's behavior is unchanged: the stored value is written back only while the field is in error, and the next save after any edit stores the document as shown. Amended 2026-10-31. The hint was not true of everything the format's own list named. A forbidden style or new-tab flag set to false is refused by DocumentValidator only when it is set, and default or an empty name on a color whose palette is empty is accepted by colorIsPermitted(), yet the load walk named both, because it takes off any key whose spec the schema leaves out, whatever the key holds. Neither is a mark or changes what renders. js/src/policy.ts's stripStyles() still takes them off, since the editor cannot load a key its schema has no spec for, and no longer names them, so every name in either list is one a save without an edit refuses. Amended 2026-11-03. The two labels read "This text format does not permit: !names" and "The editor cannot show these together, so it leaves off the first: !names", and every name inside them is built with ! placeholders too, which Drupal.t() passes through as they are: React escapes the notice's text, and a name escaped by @ first, once for itself and again in the list, showed its character references (0046).

The renderer is unchanged. A run stored before this record, or written without validation, still renders in the fixed nesting order of docs/rendering.md section 4, code outermost and <sub><sup> for the pair, so the page stays deterministic for whatever is stored.

Alternatives considered

Accepting the runs on the server and stripping them in the editor alone, which is where the upgrade left subscript with superscript. Rejected by the maintainer: the editor and the server would still disagree about what a document may hold, a save without an edit would store a document the editor cannot open as stored, and the notice's hint would have to say so.

Rewriting the run on save, in DocumentNormalizer. Rejected by the maintainer: it would take formatting off a document nobody was told about, which is the posture the module already refuses for everything a format forbids, refused by name on save rather than stripped on the way in.

Keeping code and dropping what it meets, the answer ProseMirror's own addToSet() and upstream's HTML parser give. Rejected: dropping a link loses its target and dropping a color or a style loses what the author chose, while dropping code loses only the monospace.

Listing the pairs on the client, as the upgrade did for subscript and superscript. Rejected: it is how code slipped past the upgrade, and a style an alter adds, or an exclusion a later BlockNote declares, would slip past the same way. The server's copy is a list, because the server has no schema to ask, and the vitest that pins it against the built schema is what makes an upstream change fail loudly rather than reach a site.

Building the editor first and loading the document afterwards, so that the editor's own schema could be asked. Rejected: the document would arrive as an edit, in the undo history and in the change events a module observes (docs/workflows.md), and building a second editor for each widget only to read its schema costs a whole editor per attach.

Consequences

A document stored with such a run opens with the notice naming what the editor left off. Saving it without an edit is refused with the new message; saving it after any edit stores it without the code formatting, or without the subscript. Once a save has been refused, the stripped document is written into the textarea, as for anything the format forbids, so the next save goes through.

A paste or an HTML import of code inside a link, or of code with another style, keeps the code and loses the rest, because that is upstream's parser and this record does not change it. The load walk answers the same combination the other way round. A future change to paste would be a record of its own.

The planned converter from other formats has to apply the same rule as the load walk, or its output is refused on save.

tests/fixtures/documents/inline-content.json keeps its runs that combine code with other styles, with a color and inside a link, and subscript with superscript, because they pin the renderer's nesting order for a stored document; BlockContentValidationTest records the fixture as refused for incompatible_styles as well as dangerous_link. docs/rendering.md section 4 no longer gives a seven-style run as an example of valid output, and section 9 states the rule.

An upgrade of BlockNote that changes what any mark excludes fails exclusions.test.ts until checkIncompatibleStyles() and this record move with it. A style an alter registers is excluded by code in the editor and taken off on load the same way; the server needs no rule for it, since it refuses a style key it does not know as InvalidInlineStyle anyway.

References

  • BlockNote 0.57.0: the BlockNoteEditor constructor's check of initialContent (src/editor/BlockNoteEditor.ts), the code style's excludes() (src/blocks/defaultBlocks.ts, with src/schema/markGroups.ts), the link mark (src/extensions/tiptap-extensions/Link/link.ts), and styledTextToNodes() (src/api/nodeConversions/blockToNode.ts), which makes a mark of any truthy style value.
  • ProseMirror: MarkType.excludes(), Mark.addToSet() and Node.check() in prosemirror-model; tiptap's getSchema() in @tiptap/core.
  • js/src/portable/exclusions.ts, js/src/policy.ts (separateExcludedMarks()), js/src/editor.tsx, js/src/stripped.tsx, js/src/__tests__/exclusions.test.ts.
  • src/Document/DocumentValidator.php (checkIncompatibleStyles()), src/Document/DocumentError.php, src/Plugin/Validation/Constraint/BlockNoteDocumentConstraint.php, tests/src/Unit/DocumentContentShapeTest.php.
  • 0023, the message's shape; 0031, whose server posture this record supersedes; 0032, the new-tab flag.