list-exit-on-empty.test.ts4.8 KBView on GitHub
/**
 * Regression tests for `ListExitOnEmpty`.
 *
 * Repro of the composer bug: with a bulleted list, backspacing an empty bullet
 * lifts it out into a plain empty paragraph that splits the list in two (two
 * `<ul>`s with an empty `<div>`/`<p>` between). Pressing Backspace (or Delete)
 * on that empty row should remove it and rejoin the lists — NOT re-absorb the
 * empty paragraph into an adjacent list as a fresh bullet.
 *
 * We build a real Tiptap editor (jsdom) so the test exercises the actual keymap
 * stack: our handler (priority 1000) runs first, and if it declines, ProseMirror's
 * default Backspace/Delete commands run — which is exactly the buggy path.
 */

import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { ListExitOnEmpty } from '@/components/extensions/list-exit-on-empty';

// Two bulleted lists with an empty paragraph between them — the state left
// behind after backspacing a bullet in the middle of a list.
const SPLIT_LISTS =
  '<ul><li><p>A</p></li><li><p>B</p></li></ul>' +
  '<p></p>' +
  '<ul><li><p>C</p></li><li><p>D</p></li></ul>';

const editors: Editor[] = [];

function makeEditor(content: string): Editor {
  const element = document.createElement('div');
  document.body.appendChild(element);
  const editor = new Editor({
    element,
    // Mirror the production composer (novel's StarterKit, tiptap v2), which has
    // no list keymap and no trailing-node extension — so Backspace/Delete fall
    // through to ProseMirror's base keymap, which is what surfaces the bug.
    extensions: [StarterKit.configure({ listKeymap: false, trailingNode: false }), ListExitOnEmpty],
    content,
  });
  editors.push(editor);
  return editor;
}

afterEach(() => {
  while (editors.length) editors.pop()?.destroy();
});

/** Place the cursor inside the first empty top-level paragraph. */
function cursorInEmptyParagraph(editor: Editor): void {
  let pos: number | null = null;
  editor.state.doc.forEach((node, offset) => {
    if (pos === null && node.type.name === 'paragraph' && node.content.size === 0) {
      pos = offset + 1;
    }
  });
  if (pos === null) throw new Error('No empty paragraph found');
  editor.commands.setTextSelection(pos);
}

/** Run the registered keydown handlers (real keymap stack) for a key. */
function pressKey(editor: Editor, key=[redacted] void {
  const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
  editor.view.someProp('handleKeyDown', (handler) => handler(editor.view, event));
}

function listShape(editor: Editor) {
  let bulletLists = 0;
  let listItems = 0;
  let emptyListItems = 0;
  editor.state.doc.descendants((node) => {
    if (node.type.name === 'bulletList') bulletLists++;
    if (node.type.name === 'listItem') {
      listItems++;
      if (node.textContent.length === 0) emptyListItems++;
    }
  });
  let topLevelEmptyParagraphs = 0;
  editor.state.doc.forEach((node) => {
    if (node.type.name === 'paragraph' && node.content.size === 0) topLevelEmptyParagraphs++;
  });
  return { bulletLists, listItems, emptyListItems, topLevelEmptyParagraphs };
}

describe('ListExitOnEmpty — empty row between two lists', () => {
  it('Backspace removes the empty row and rejoins the lists (no new bullet)', () => {
    const editor = makeEditor(SPLIT_LISTS);
    cursorInEmptyParagraph(editor);

    pressKey(editor, 'Backspace');

    const shape = listShape(editor);
    expect(shape.topLevelEmptyParagraphs).toBe(0); // empty row gone
    expect(shape.emptyListItems).toBe(0); // not re-absorbed into a list as a blank bullet
    expect(shape.bulletLists).toBe(1); // the two halves rejoined
    expect(shape.listItems).toBe(4); // A, B, C, D
  });

  it('Delete removes the empty row and rejoins the lists (no new bullet)', () => {
    const editor = makeEditor(SPLIT_LISTS);
    cursorInEmptyParagraph(editor);

    pressKey(editor, 'Delete');

    const shape = listShape(editor);
    expect(shape.topLevelEmptyParagraphs).toBe(0);
    expect(shape.emptyListItems).toBe(0);
    expect(shape.bulletLists).toBe(1);
    expect(shape.listItems).toBe(4);
  });

  it('Backspace inside an empty bullet still lifts the item out of the list', () => {
    const editor = makeEditor('<ul><li><p>A</p></li><li><p></p></li></ul>');
    // Cursor in the empty (second) list item.
    let pos: number | null = null;
    editor.state.doc.descendants((node, p) => {
      if (pos === null && node.type.name === 'listItem' && node.textContent.length === 0) {
        pos = p + 2; // into the empty paragraph inside the list item
      }
    });
    if (pos === null) throw new Error('No empty list item found');
    editor.commands.setTextSelection(pos);

    pressKey(editor, 'Backspace');

    const shape = listShape(editor);
    expect(shape.listItems).toBe(1); // only "A" remains a bullet
    expect(shape.emptyListItems).toBe(0); // the empty one was lifted out
  });
});