Repository navigation
Stop the pad adding an empty H1 at the end of every document #572
Description
Summary
The pad adds an empty level-1 heading at the end of a document. It happens after Settings → Markdown replace, and after any edit that leaves a paragraph, list, table or other non-heading block last. The empty heading is saved. It is a real section: it gets a toc-id and can get a heading chat.
Users complain about it. The MCP get_outline tool shows it as the last section:
- (untitled) [level 1]
It reproduces every time on /demo. docs/DEMO.md ends with a paragraph ("Filtered to this section? Show the whole document."), but the outline after the replace ends with "(untitled)".
This is not #474. #474 is about new toc-ids on replace. This bug happens on any edit, with or without a replace.
Root cause
- StarterKit registers Tiptap
TrailingNodeby default. The pad config does not turn it off (TipTap.tsx:140-160). - With no
nodeoption,TrailingNodeinsertsschema.topNodeType.contentMatch.defaultType. Read from@tiptap/extensions3.31.3,src/trailing-node/trailing-node.ts. - The pad's top node is
TitleDocument, withcontent: 'heading block*'(title-document.ts:14). So the default type isheading, and itsleveldefaults to 1. - The plugin runs in
appendTransactionafter every change whose last block is not a heading. y-sync then writes the new heading to Yjs, and the worker stores it. - The server is not the cause.
importMarkdownondocs/DEMO.mdreturns 345 blocks that end with the paragraph, with no empty heading. The ProseMirror replace step thatsetContentruns is clean too. - The behaviour is old.
@tiptap/extensions3.15.3 has the same fallback.
Earlier work saw the node and worked around it. A Cypress helper says "StarterKit's trailingNode appends an empty heading whenever the document ends in another block" (commands.ts:72-76). #370 saw it too and left it out of scope. The TOC drops empty headings (useToc.ts:78), and the upstream heading Markdown renderer writes nothing for one, so neither showed it. The MCP outline does not drop it, so it surfaced there.
The new-document seed ends in empty paragraphs (config/index.ts:33-38, read from code). So almost every document made in the webapp has stored one empty H1.
Reproduce
Measured on the local stack (make dev-local), 2026-10-22.
- Open a new pad, for example
/repro-trailing-h1-a. - In the console, run the same call as Markdown replace:
document.querySelector('.docy_editor .ProseMirror').editor.commands.setContent({ type: 'doc', content: [ /* H1, H2, paragraph */ ] }) - The last block is now
heading1:"". The only active trailing plugin istrailingNode$. - Wait for the save, then decode the stored head from
docsy-postgres-local. Version 1 ends withheading2:"Section",paragraph:"Last paragraph.",heading1:"".
Fix
trailingNode: false in the pad StarterKit config. The same config serves the pad, the history editor and the /editor playground. The chat composer has its own config and is not changed.
Checked after the fix:
- A replace that ends in a paragraph stores a head that ends in that paragraph.
- A pad whose stored head already ended in an empty H1 loses it on the next replace.
- Gapcursor is still on. A click below a final table, then typing, makes a paragraph after the table.
- A click below a final code block puts the caret inside the code. ArrowDown at its end makes a paragraph below it. Before the fix, the empty H1 was the click target there.
The fix also deletes two workarounds that existed only for this node. The first is the Cypress helper haveNamedHeadingCount; its 8 call sites now use plain have.length. The second is the trailing-extras tolerance in buildBlockRanges, which now needs an exact block count.
Regression spec: apps/webapp/cypress/e2e/editor/heading/heading-no-trailing.cy.js. It passes with the fix and fails without it (expected 'H1' to equal 'P').
A tab that still runs the old bundle keeps adding the H1, also after remote changes, until it reloads.
Open decisions
- Should we clean up stored documents that already end in an empty H1?
- Recommended: no, not now. Re-paste
/demoafter the deploy instead. - Reason: the fix stops new ones, and a replace or an edit removes old ones. A server-side sweep writes every affected document, mints a version row for each, and puts one "Untitled heading" removed row in the next digest.
- If the maintainer wants a sweep, it gets its own issue.
- Recommended: no, not now. Re-paste
- Should the pad give a mouse way out below a final code block, Mermaid block or folded section?
- Recommended: no, not now. Keep the keyboard exits (ArrowDown, triple Enter) and the TOC "Add headings" button.
- Today a click below a final code block puts the caret in the code. A click below a folded last section puts the caret at the end of its heading.
- Reason: a trailing paragraph (
trailingNode: { node: 'paragraph' }) stores one more empty block on most documents. That adds MCP block numbers and version and digest noise. - I found no primary source for what Google Docs, Word and Notion do here. The maintainer should rule before anyone builds a narrower rule.
After the deploy
Re-paste docs/DEMO.md on /demo only from a freshly reloaded tab, with every other owner tab on /demo closed. A tab that still runs the old bundle adds the H1 again after the remote change. Then check get_outline.
Acceptance criteria
- A Markdown replace whose file ends in a paragraph stores a document that ends in that paragraph.
- An edit that leaves a non-heading block last adds no heading.
-
get_outlineon/demoshows no "(untitled)" section after the next replace. - A click below a final table or media block still places a caret there.
Activity
fix(editor): stop the trailing empty H1 (#572)
HMarzban commentedon Oct 22, 2026
Fixed by 659dab2 (
trailingNode: falsein the pad's StarterKit config) and deployed with 0121170.Checked on production: after a Markdown replace from a freshly loaded tab, the
demodocument ends with its last paragraph. The MCPget_outlineshows no "(untitled)" section.A document that already ends in an empty H1 keeps it until its next replace or edit. As recommended in the issue, we add no cleanup sweep and no extra way to click below a final code block for now.