Affected Packages
@tiptap/markdown
Tiptap Version
3.28.0
Browser Used
Chrome
What happened?
Parsing markdown that contains an inline HTML comment inside a heading crashes the editor:
RangeError: Invalid content for node type heading
at setNodeMarkup
at appendTransaction (@tiptap/extension-table-of-contents)
Steps to reproduce
Load this markdown into an editor built from StarterKit + @tiptap/extension-table-of-contents, in a browser (real DOM present):
The document loads, then the table-of-contents plugin throws on the next transaction.
Root cause
parseHTMLToken in MarkdownManager routes unrecognized HTML through generateJSON (a real ProseMirror DOM parse). This path is only taken when window.DOMParser exists — headless, it already falls back to literal text, so the bug is browser-only.
generateJSON("<!-- date -->") has no element to keep, so it returns an empty paragraph: { type: "doc", content: [{ type: "paragraph" }] }.
- For an inline comment token, that empty block
paragraph gets spliced into the heading's inline content — an invalid document that nonetheless loads.
@tiptap/extension-table-of-contents calls setNodeMarkup on every heading on every transaction (to stamp data-toc-id), which re-validates the node → RangeError. A comment in a heading is the worst case because the TOC plugin touches every heading constantly.
The unrecognized-HTML guard added in #7916 keys off tag names via /<\/?([a-zA-Z][\w-]*)/, which never matches <!--. So a comment is treated as recognized HTML and still hits generateJSON, instead of being sent through the existing htmlAsLiteralText fallback.
Suggested fix
In MarkdownManager.parseHTMLToken, detect comment-only markup before the DOM parse and fall back to literal text:
if (!html.trim()) {
return null;
}
+ // A comment-only token has no element for the DOM parser to keep, so
+ // generateJSON reduces it to an empty paragraph; returning that block node
+ // into inline content crashes on re-validation. Comments carry no tag name,
+ // so isUnrecognizedHtml misses them. Pass comment-only markup through as text.
+ if (/^\s*(?:<!--[\s\S]*?-->\s*)+$/.test(html)) {
+ return this.htmlAsLiteralText(html, !!token.block);
+ }
if (this.isUnrecognizedHtml(html)) {
return this.htmlAsLiteralText(html, !!token.block);
}
Detecting the comment before the parse (rather than checking for an empty paragraph after) matters: <!-- x --> and a genuine empty <p></p> both reduce to an empty paragraph, so a post-parse check would wrongly convert real empty <p></p> blocks to literal text too. The up-front check is comment-specific.
Environment
@tiptap/markdown@3.28.0
- Browser (bug does not reproduce headless / without
window.DOMParser)
- With
@tiptap/extension-table-of-contents (any extension that runs setNodeMarkup on headings triggers it; TOC is just the most common)
Expected Behavior
A comment-only token should be passed through via htmlAsLiteralText (matching the headless path and CommonMark's "raw HTML passes through verbatim" semantics), not turned into an empty block node in inline content.
Reproducible Example URL (Optional)
No response
Additional Context (Optional)
No response
Affected Packages
@tiptap/markdown
Tiptap Version
3.28.0
Browser Used
Chrome
What happened?
Parsing markdown that contains an inline HTML comment inside a heading crashes the editor:
Steps to reproduce
Load this markdown into an editor built from
StarterKit+@tiptap/extension-table-of-contents, in a browser (real DOM present):## Week of <!-- date -->The document loads, then the table-of-contents plugin throws on the next transaction.
Root cause
parseHTMLTokeninMarkdownManagerroutes unrecognized HTML throughgenerateJSON(a real ProseMirror DOM parse). This path is only taken whenwindow.DOMParserexists — headless, it already falls back to literal text, so the bug is browser-only.generateJSON("<!-- date -->")has no element to keep, so it returns an empty paragraph:{ type: "doc", content: [{ type: "paragraph" }] }.paragraphgets spliced into the heading's inline content — an invalid document that nonetheless loads.@tiptap/extension-table-of-contentscallssetNodeMarkupon every heading on every transaction (to stampdata-toc-id), which re-validates the node →RangeError. A comment in a heading is the worst case because the TOC plugin touches every heading constantly.The unrecognized-HTML guard added in #7916 keys off tag names via
/<\/?([a-zA-Z][\w-]*)/, which never matches<!--. So a comment is treated as recognized HTML and still hitsgenerateJSON, instead of being sent through the existinghtmlAsLiteralTextfallback.Suggested fix
In
MarkdownManager.parseHTMLToken, detect comment-only markup before the DOM parse and fall back to literal text:if (!html.trim()) { return null; } + // A comment-only token has no element for the DOM parser to keep, so + // generateJSON reduces it to an empty paragraph; returning that block node + // into inline content crashes on re-validation. Comments carry no tag name, + // so isUnrecognizedHtml misses them. Pass comment-only markup through as text. + if (/^\s*(?:<!--[\s\S]*?-->\s*)+$/.test(html)) { + return this.htmlAsLiteralText(html, !!token.block); + } if (this.isUnrecognizedHtml(html)) { return this.htmlAsLiteralText(html, !!token.block); }Detecting the comment before the parse (rather than checking for an empty paragraph after) matters:
<!-- x -->and a genuine empty<p></p>both reduce to an empty paragraph, so a post-parse check would wrongly convert real empty<p></p>blocks to literal text too. The up-front check is comment-specific.Environment
@tiptap/markdown@3.28.0window.DOMParser)@tiptap/extension-table-of-contents(any extension that runssetNodeMarkupon headings triggers it; TOC is just the most common)Expected Behavior
A comment-only token should be passed through via
htmlAsLiteralText(matching the headless path and CommonMark's "raw HTML passes through verbatim" semantics), not turned into an empty block node in inline content.Reproducible Example URL (Optional)
No response
Additional Context (Optional)
No response