siyuan-note/siyuan · error

heading [%s] is a leaf block and cannot have children; to pl

Error message

heading [%s] is a leaf block and cannot have children; to place a block below this heading, pass previousID=<heading id> or previousID=<last block below the heading> instead of parentID

What it means

Returned by CheckContainerParent when the resolved block's Type is "h" (heading). Headings are leaf blocks in SiYuan's data model — their apparent sub-content is actually following siblings computed via HeadingChildren, not AST children. Attaching a block as an AST child of a heading is illegal nesting.

Source

Thrown at kernel/treenode/blocktree.go:550

	return false
}

// CheckContainerParent 校验 parentID 指向的块是否允许接收子块。
// 仅在“通过 parentID 定位插入/移动目标”(即不依赖 previousID/nextID)的场景下调用,
// 因为一旦带 previousID/nextID,事务层走的是兄弟级 InsertAfter/InsertBefore,天然合法。
// 返回 nil 表示合法;返回 error 时调用方应拒绝本次操作。
func CheckContainerParent(parentID string) error {
	bt := GetBlockTree(parentID)
	if nil == bt {
		return fmt.Errorf("parent block not found: %s", parentID)
	}
	if IsContainerType(bt.Type) {
		return nil
	}
	if "h" == bt.Type {
		// 标题是叶子块,其“子内容”在数据结构上实为后续兄弟节点(由 HeadingChildren 按层级推算)。
		// 把块挂成标题的 AST 子节点属于非法嵌套,应改用 previousID 定位。
		return fmt.Errorf("heading [%s] is a leaf block and cannot have children; to place a block below this heading, pass previousID=<heading id> or previousID=<last block below the heading> instead of parentID", parentID)
	}
	return fmt.Errorf("block [%s] type %q is a leaf block and cannot have children; use previousID to place the block as its sibling instead", parentID, bt.Type)
}

// CheckListItemNesting 校验 parentID 和 childID 是否形成“列表项直含列表项”的非法嵌套。
// 嵌套列表的正确结构是 ListItem > List > ListItem,列表项不能直接作为另一个列表项的子块。
// 仅在 move 场景调用(源和目标类型均已知)。
func CheckListItemNesting(parentID, childID string) error {
	parentBt := GetBlockTree(parentID)
	childBt := GetBlockTree(childID)
	if nil == parentBt || nil == childBt {
		return nil // 查不到就放行,不阻塞未知场景
	}
	if "i" == parentBt.Type && "i" == childBt.Type {
		return fmt.Errorf("a list-item cannot directly contain another list-item; to nest, first create a list (NodeList) under the outer list-item, then add the inner list-items to that list")
	}
	return nil
}

View on GitHub (pinned to 251596fc0d)

Solutions

  1. Use previousID=<heading id> to place the new block as the heading's first following sibling.
  2. Or use previousID=<id of the last block currently below the heading> to append at the end of that heading's section.
  3. Do not pass a heading id as parentID; CheckContainerParent will always reject it.

Example fix

// before
InsertBlock(parentID: headingID, data: block) // -> heading is a leaf block

// after: place as sibling using previousID
InsertBlock(previousID: headingID, data: block)
Defensive patterns

Strategy: validation

Validate before calling

// Headings are leaf blocks; route heading-targeted placement through previousID.
bt := treenode.GetBlockTree(parentID)
if bt != nil && bt.Type == "h" {
    return fmt.Errorf("heading %s is a leaf; use previousID instead of parentID", parentID)
}
return treenode.CheckContainerParent(parentID)

Prevention

When it happens

Trigger: Calling a parentID-based insert or move with parentID set to a heading block id. CheckContainerParent is run by block insert/move in api/block_op.go, MCP tools/block.go, and cli/cmd/block.go.

Common situations: An integration assumes headings can be containers (common in DOM/HTML thinking) and passes the heading id as parentID; moving a block 'under' a heading via the wrong parameter.

Related errors


AI-assisted analysis of siyuan-note/siyuan@251596fc0d (2026-08-12). Data as JSON: /api/errors/0c35c8af9cbdd6dd. Report an issue: GitHub.