The Problem
I wanted "smart" numbered lists that maintain their sequence even when split by paragraphs:
Expected behavior:
1. Apple
2. Banana
3. Cherry ← continues from 2
4. Date
Actual behavior (default TipTap/ProseMirror):
1. Apple
2. Banana
1. Cherry ← resets to 1!
2. Date
When pressing Enter on an empty list item, the list splits and the second list resets to 1.
Why Claude Code Got Stuck
This problem was deceptively complex:
-
Multiple failed approaches — Tried
newListboolean flag, list grouping by ID, and fixedstartFromvalues -
TipTap's
liftListItemcopies attributes — When splitting a list, the new list inherits the original's attributes -
Can't distinguish "new list" from "split list" — Both end up with
startFrom=1
Claude Code spent many iterations trying:
- Boolean
newListflag → Failed because split lists copy the flag - List group IDs → Too complex, collision issues
- Fixed
startFromon creation → Breaks when lists are split multiple times
Root Cause Analysis
The fundamental issue: a static attribute can't handle dynamic list operations.
When you split a list:
-
liftListItemcreates a neworderedListnode - The new list copies attributes from the original (including
startFrom=1) - There's no way to know if
startFrom=1means "intentionally new" or "accidentally copied"
The key insight: Instead of storing the correct number, store the intent (startFrom=1 = new list, startFrom≠1 = continuation) and recalculate on every update.
The Solution
Strategy: Dynamic recalculation on every document update
1. Custom OrderedList with startFrom attribute:
// custom-ordered-list.ts
import OrderedList from '@tiptap/extension-ordered-list'
export const CustomOrderedList = OrderedList.extend({
addAttributes() {
return {
...this.parent?.(),
startFrom: {
default: 1,
parseHTML: element => {
const start = element.getAttribute('start')
return start ? parseInt(start, 10) : 1
},
renderHTML: attributes => {
return { start: attributes.startFrom ?? 1 }
},
},
}
},
})
2. Recalculate all lists on every update:
// In your editor component
const renumberAllOrderedLists = (editorInstance: Editor) => {
const { state, view } = editorInstance
const { doc } = state
const tr = state.tr
let needsUpdate = false
let runningNumber = 0
// Collect all orderedLists
const lists: { pos: number; node: any }[] = []
doc.descendants((node, pos) => {
if (node.type.name === 'orderedList') {
lists.push({ pos, node })
}
})
// Recalculate startFrom for each list
for (const { pos, node } of lists) {
const currentStartFrom = node.attrs.startFrom ?? 1
if (currentStartFrom === 1) {
// New list group: start from 1
runningNumber = 1 + node.childCount
} else {
// Continuation: should start from runningNumber
if (currentStartFrom !== runningNumber) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
startFrom: runningNumber
})
needsUpdate = true
}
runningNumber = runningNumber + node.childCount
}
// Update list item values
let itemIndex = 0
const effectiveStartFrom = currentStartFrom === 1 ? 1 : runningNumber - node.childCount
node.forEach((itemNode, offset) => {
if (itemNode.type.name === 'listItem') {
const newValue = effectiveStartFrom + itemIndex
const itemPos = pos + 1 + offset
if (itemNode.attrs.value !== newValue) {
tr.setNodeMarkup(itemPos, undefined, {
...itemNode.attrs,
value: newValue
})
needsUpdate = true
}
itemIndex++
}
})
}
if (needsUpdate && tr.docChanged) {
view.dispatch(tr)
}
}
// Call on every update
const editor = useEditor({
// ...
onUpdate: ({ editor }) => {
setTimeout(() => renumberAllOrderedLists(editor), 0)
},
})
3. Mark split lists as "continuation" (the key trick!):
// custom-list-item.ts - Enter key handler
Enter: ({ editor }) => {
// ... find listItem depth ...
if (isEmpty) {
const result = editor.commands.liftListItem('listItem')
if (result) {
// After split, mark the new list as "continuation"
requestAnimationFrame(() => {
const { state, view } = editor
const { doc, selection } = state
const cursorPos = selection.from
const tr = state.tr
// Find the first orderedList after cursor
let found = false
doc.descendants((node, pos) => {
if (found) return false
if (node.type.name === 'orderedList' && pos > cursorPos) {
found = true
// Set startFrom to 2 (any non-1 value marks it as continuation)
if (node.attrs.startFrom === 1) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
startFrom: 2 // Temporary value, recalculated on next update
})
}
return false
}
})
if (tr.docChanged) view.dispatch(tr)
})
}
return result
}
return editor.commands.splitListItem('listItem')
}
Key Takeaways for AI Agents
Claude Code users: if you're hitting this, try:
- Don't use boolean flags for list grouping —
liftListItemcopies all attributes - Use
startFrom=1as "new list" marker,startFrom≠1as "continuation" marker - Recalculate all list numbers on every document update (computed, not stored)
- After
liftListItem, userequestAnimationFrameto mark the split list as continuation
The breakthrough insight:
"Computed over stored" — For data affected by user edits, recalculating on every update is more robust than trying to maintain correct values through all possible operations.
Keywords for search:
ProseMirror ordered list numbering, TipTap list split reset, liftListItem copies attributes, orderedList startFrom attribute, smart numbered list TipTap, list continuation after paragraph
This article is part of the "Claude Code Debugging Chronicles" series, documenting real debugging sessions with AI coding assistants.
Related Articles:
Top comments (0)