Skip to content

Stop the pad adding an empty H1 at the end of every document #572

Closed

Description

@HMarzban

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 TrailingNode by default. The pad config does not turn it off (TipTap.tsx:140-160).
  • With no node option, TrailingNode inserts schema.topNodeType.contentMatch.defaultType. Read from @tiptap/extensions 3.31.3, src/trailing-node/trailing-node.ts.
  • The pad's top node is TitleDocument, with content: 'heading block*' (title-document.ts:14). So the default type is heading, and its level defaults to 1.
  • The plugin runs in appendTransaction after 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. importMarkdown on docs/DEMO.md returns 345 blocks that end with the paragraph, with no empty heading. The ProseMirror replace step that setContent runs is clean too.
  • The behaviour is old. @tiptap/extensions 3.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.

  1. Open a new pad, for example /repro-trailing-h1-a.
  2. In the console, run the same call as Markdown replace:
    document.querySelector('.docy_editor .ProseMirror').editor.commands.setContent({ type: 'doc', content: [ /* H1, H2, paragraph */ ] })
  3. The last block is now heading1:"". The only active trailing plugin is trailingNode$.
  4. Wait for the save, then decode the stored head from docsy-postgres-local. Version 1 ends with heading2:"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

  1. Should we clean up stored documents that already end in an empty H1?
    • Recommended: no, not now. Re-paste /demo after 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.
  2. 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_outline on /demo shows no "(untitled)" section after the next replace.
  • A click below a final table or media block still places a caret there.

Activity

  1. added
    bugSomething isn't working
    EditorTiptap & Prosemirror
    on Oct 22, 2026
  2. added this to the v2.3.0 milestone on Oct 22, 2026
  3. HMarzban commented on Oct 22, 2026

    @HMarzban
    CollaboratorAuthor

    Fixed by 659dab2 (trailingNode: false in the pad's StarterKit config) and deployed with 0121170.

    Checked on production: after a Markdown replace from a freshly loaded tab, the demo document ends with its last paragraph. The MCP get_outline shows 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    EditorTiptap & ProsemirrorbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions