markdown-editor-liveness.test.tsx3.2 KBView on GitHub
import { render } from '@testing-library/react';

/**
 * `MarkdownEditor` announces its editor to the parent through `onEditorCreated`, fired from an
 * effect on `[editor]`. TipTap builds the editor a tick after mount and tears it down on a 1ms
 * timer after unmount — and a destroyed editor is NOT null, it keeps its identity while
 * `commandManager`/`view`/`state` are nulled underneath it. So a surface that disappears in that
 * gap (the conversation Overview tab, the instant an artifact opens over it) leaves the effect
 * holding a live-LOOKING editor.
 *
 * Every consumer callback reaches for `.commands` or `.getMarkdown()` first thing, so the throw
 * lands inside a React effect and escapes to the route error boundary — replacing the whole page
 * with "Something went wrong!" rather than failing quietly. That is what took Cedar down for
 * four users on 2026-08-14.
 *
 * These tests drive `useEditor` directly so the destroyed state is exact rather than timing-dependent.
 */

const mockUseEditor = jest.fn();

jest.mock('@tiptap/react', () => {
  const actual = jest.requireActual('@tiptap/react');
  return {
    ...actual,
    useEditor: (...args: unknown[]) => mockUseEditor(...args),
    EditorContent: () => null,
  };
});

import { MarkdownEditor } from '@/components/markdown-editor';

/** Mirrors a real destroyed TipTap editor: non-null, but `.commands` throws when touched. */
function destroyedEditor() {
  return {
    isDestroyed: true,
    get commands(): { setContent: (md: string) => void } {
      throw new TypeError("Cannot read properties of null (reading 'commands')");
    },
    getMarkdown: () => '',
  };
}

function liveEditorStub() {
  const setContent = jest.fn();
  return { editor: { isDestroyed: false, commands: { setContent }, getMarkdown: () => '' }, setContent };
}

describe('MarkdownEditor onEditorCreated liveness', () => {
  afterEach(() => {
    mockUseEditor.mockReset();
  });

  it('does not announce a DESTROYED editor — the case `if (editor)` misses', () => {
    mockUseEditor.mockReturnValue(destroyedEditor());
    const onEditorCreated = jest.fn((editor: { commands: { setContent: (md: string) => void } }) => {
      // What NextStepsCard and UniversalComposer really do on creation.
      editor.commands.setContent('next steps');
    });

    // Before the guard this threw out of the effect and into the route error boundary.
    expect(() => render(<MarkdownEditor onEditorCreated={onEditorCreated} />)).not.toThrow();
    expect(onEditorCreated).not.toHaveBeenCalled();
  });

  it('still announces a live editor, and seeding works', () => {
    const { editor, setContent } = liveEditorStub();
    mockUseEditor.mockReturnValue(editor);

    render(
      <MarkdownEditor
        onEditorCreated={(e: { commands: { setContent: (md: string) => void } }) =>
          e.commands.setContent('next steps')
        }
      />,
    );

    expect(setContent).toHaveBeenCalledWith('next steps');
  });

  it('does not announce anything while the editor is still null', () => {
    mockUseEditor.mockReturnValue(null);
    const onEditorCreated = jest.fn();

    render(<MarkdownEditor onEditorCreated={onEditorCreated} />);

    expect(onEditorCreated).not.toHaveBeenCalled();
  });
});