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();
});
});