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