Deleting the last character of a list item creates new list item

When I backspace the last character of a list item, it automatically creates a new empty list item without deleting that last character.

Screen Recording 2025-05-22 at 12.38.13 AM_converted

I tried to debug it using debugger and turns out it’s replacing the paragraph node with <br>:

<li>
  <br>
</li>

It should be like this:

<li>
  <p>
    <br>
  </p>
</li>

While it’s replacing the <p> with <br>, the document observer thinks it’s new node and emits “Enter” key event that’s creating another list item.

Which browser and platform is this happening on? And can you reproduce this on the demo editor on prosemirror.net or does it require a custom schema/setup?

I think this plugin is culprit. it’s used to assign unique ids to nodes:

import { Extension } from "@tiptap/core"
import { Plugin, PluginKey } from "@tiptap/pm/state"
import { nanoid } from "nanoid"

export const UniqueIDKey = new PluginKey("uniqueID")

const ID_TARGET_NODES = new Set([
  "paragraph",
  "heading",
  "table",
  "blockquote",
  "bulletList",
  "orderedList",
  "tableRow",
  "listItem",
])

const NODE_ID_LENGTH = 8

export function applyUniqueIds(tr) {
  let modified = false
  // Store newly generated IDs in this transaction to check for immediate duplicates
  const generatedIdsThisTransaction = new Set()

  // Iterate through the changes in the new document state
  tr.doc.descendants((node, pos) => {
    // Check if it's a target node type
    if (ID_TARGET_NODES.has(node.type.name)) {
      // check if node id is duplicate or not
      if (!node.attrs.id || generatedIdsThisTransaction.has(node.attrs.id)) {
        let newId = nanoid(NODE_ID_LENGTH)

        // Simple collision check within this transaction (extremely unlikely but safe)
        while (generatedIdsThisTransaction.has(newId)) {
          console.warn(`NanoID collision within transaction for ${node.type.name}, regenerating...`)
          newId = nanoid(NODE_ID_LENGTH)
        }

        tr.setNodeAttribute(pos, "id", newId)
        generatedIdsThisTransaction.add(newId)
        modified = true
      } else {
        // store this to check for duplicates
        generatedIdsThisTransaction.add(node.attrs.id)
      }
    }
    // Continue descent for other nodes (e.g., document, lists containing listItems)
    return true
  })

  // Return the modified transaction if any IDs were added, otherwise null
  return modified ? tr : null
}

export const UniqueID = Extension.create({
  name: "uniqueID",

  // Define the 'id' attribute globally for target nodes
  addGlobalAttributes() {
    return [
      {
        types: Array.from(ID_TARGET_NODES),
        attributes: {
          id: {
            default: null, // Start with null, ID assigned by plugin
            parseHTML: (element) => element.id,
            renderHTML: (attributes) => {
              // Only render the attribute if it has a value
              return attributes.id ? { id: attributes.id } : {}
            },
            // Don't copy ID on split; the new node will get its own ID via the plugin
            keepOnSplit: false,
          },
        },
      },
    ]
  },

  // Use a ProseMirror plugin to assign IDs to newly created nodes
  addProseMirrorPlugins() {
    return [
      new Plugin({
        key: UniqueIDKey,
        // 'appendTransaction' is called after each transaction, allowing us to modify it
        appendTransaction: (transactions, oldState, newState) => {
          const tr = newState.tr

          // Only process transactions that changed the document
          if (!transactions.some((transaction) => transaction.docChanged)) {
            return null
          }

          return applyUniqueIds(tr)
        },
      }),
    ]
  },
})

I also have this keyboard shortcut:

  addKeyboardShortcuts() {
    return {
      Enter: () => {
        return this.editor.chain().splitListItem("listItem").setSpacing({ before: 0, after: 0 }).run()
      },
    }
  },

If I remove the unique id plugin and this keyboard shortcut it works fine.

Can you take a look? I am sure what’s wrong with this.

No, sorry. I have enough code to maintain that I wrote—I’m not available to review other people’s code for free.

Lol, Okay. Just to summarize it has a plugin that adds unique id to following nodes:

[ “paragraph”, “heading”, “table”, “blockquote”, “bulletList”, “orderedList”, “tableRow”, “listItem”, ]

It uses tr.setNodeAttributes to do so. Is there any better way to do this?