composite-dom-render.test.tsx3.8 KBView on GitHub /**
* DOM-level regression guard for the "playbook opens empty" bug.
*
* Root cause: TipTap renders its React NodeViews (the playbook's section/scope
* cards — all visible content) via `flushSync`. React 18/19 refuses flushSync
* when it's called from inside a lifecycle/effect ("cannot flush when React is
* already rendering"), so calling `editor.commands.setContent(...)` synchronously
* inside an effect leaves the node views unmounted and the editor visually empty
* despite a fully-populated doc. CompositePlaybookDocument fixes this by deferring
* setContent to a microtask (runs just after commit, when flushSync is allowed).
*
* This test seeds the same way and asserts BOTH that the prose renders into the
* DOM AND that no flushSync-in-lifecycle error is emitted. Removing the microtask
* deferral (seeding synchronously in the effect) re-triggers that error and fails.
*/
import { useEffect, useRef } from 'react';
import { render, screen } from '@testing-library/react';
import { useEditor, EditorContent } from '@tiptap/react';
import type { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
jest.mock('@/modules/documents/playbook/usePlaybookAop', () => ({
usePlaybookAop: () => ({
aopName: 'Deals',
conversationFieldDefs: {},
customFieldDefs: {},
statusOptions: [],
}),
}));
import { mergeComposite, type PMNode } from '@/modules/documents/playbook/composite-merge';
import { createCompositePlaybookExtensions } from '@/modules/documents/playbook/playbookExtensions';
const para = (text?: string): PMNode =>
text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' };
const userJson: PMNode = {
type: 'doc',
content: [
{
type: 'globalSection',
content: [
{
type: 'crossCuttingSection',
content: [
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'IGNORE_MARKER' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [para('a rule line')] }] },
],
},
],
},
{
type: 'stageSection',
attrs: { id: 'prospecting', label: 'Prospecting' },
content: [
{ type: 'stageEntry', content: [para('entered')] },
{ type: 'stageExit', content: [para('exited')] },
{ type: 'stageInstructions', content: [para('STAGE_MARKER')] },
],
},
],
};
function Harness() {
const seeded = useRef(false);
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
...createCompositePlaybookExtensions({ aopId: 'test-aop' }),
],
content: '',
immediatelyRender: true,
});
useEffect(() => {
if (editor && !seeded.current) {
seeded.current = true;
// Mirrors CompositePlaybookDocument.seed: defer out of the effect so
// TipTap's flushSync node-view render is allowed.
queueMicrotask(() => {
if (!editor.isDestroyed) {
editor.commands.setContent(mergeComposite(userJson, null) as never);
}
});
}
}, [editor]);
return <EditorContent editor={editor as Editor} />;
}
describe('composite playbook DOM render', () => {
it('renders loaded prose into the DOM without a flushSync-in-lifecycle error', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
render(<Harness />);
expect(await screen.findByText('IGNORE_MARKER')).toBeInTheDocument();
expect(screen.getByText('a rule line')).toBeInTheDocument();
expect(screen.getByText('STAGE_MARKER')).toBeInTheDocument();
const flushSyncErrors = errorSpy.mock.calls.filter((call: unknown[]) =>
call.some((arg: unknown) => typeof arg === 'string' && arg.includes('flushSync')),
);
expect(flushSyncErrors).toEqual([]);
} finally {
errorSpy.mockRestore();
}
});
});