🐛 fix: fix internal editor onTextChange issue and add test case (#11509)

* fix internal editor onTextChange issue and add test case

* fix tests
This commit is contained in:
Arvin Xu
2026-01-15 13:15:43 +08:00
committed by GitHub
parent d4561af381
commit e5eb03ee01
4 changed files with 677 additions and 10 deletions

View File

@@ -0,0 +1,630 @@
/**
* @vitest-environment happy-dom
*/
import { type IEditor, moment } from '@lobehub/editor';
import { useEditor } from '@lobehub/editor/react';
import { act, cleanup, render, waitFor } from '@testing-library/react';
import { memo, useEffect, useRef } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import InternalEditor, { type InternalEditorProps } from './InternalEditor';
// Suppress console.warn for expected errors in tests
const originalWarn = console.warn;
beforeEach(() => {
console.warn = vi.fn();
});
afterEach(() => {
console.warn = originalWarn;
cleanup();
});
/**
* Test wrapper component that creates a real editor using useEditor hook
* This ensures all plugins and services are properly initialized
*/
interface TestWrapperProps extends Omit<InternalEditorProps, 'editor'> {
onEditorReady?: (editor: IEditor) => void;
}
const TestWrapper = memo<TestWrapperProps>(({ onEditorReady, ...props }) => {
const editor = useEditor();
const readyRef = useRef(false);
useEffect(() => {
if (editor && !readyRef.current) {
readyRef.current = true;
onEditorReady?.(editor);
}
}, [editor, onEditorReady]);
if (!editor) return null;
return <InternalEditor editor={editor} {...props} />;
});
TestWrapper.displayName = 'TestWrapper';
/**
* Test wrapper for tests that need custom plugins (no toolbar dependencies)
*/
const MinimalTestWrapper = memo<TestWrapperProps>(({ onEditorReady, plugins, ...props }) => {
const editor = useEditor();
const readyRef = useRef(false);
useEffect(() => {
if (editor && !readyRef.current) {
readyRef.current = true;
onEditorReady?.(editor);
}
}, [editor, onEditorReady]);
if (!editor) return null;
// Use minimal plugins that don't require toolbar services
const minimalPlugins = plugins || [];
return <InternalEditor editor={editor} plugins={minimalPlugins} {...props} />;
});
MinimalTestWrapper.displayName = 'MinimalTestWrapper';
describe('InternalEditor', () => {
describe('rendering', () => {
it('should render editor with real editor instance', async () => {
const { container } = render(<MinimalTestWrapper />);
await act(async () => {
await moment();
});
// Editor should be rendered
expect(container.querySelector('[data-lexical-editor]')).not.toBeNull();
});
it('should render with custom placeholder', async () => {
const placeholder = 'Start typing here...';
const { container } = render(<MinimalTestWrapper placeholder={placeholder} />);
await act(async () => {
await moment();
});
expect(container.textContent).toContain(placeholder);
});
it('should apply custom styles', async () => {
const customStyle = { backgroundColor: 'red', paddingTop: 100 };
const { container } = render(<MinimalTestWrapper style={customStyle} />);
await act(async () => {
await moment();
});
// Find the Editor component's container
const editorContainer = container.querySelector('[data-lexical-editor]')?.closest('div');
// The style should include paddingBottom: 64 (default) merged with custom styles
expect(editorContainer).toBeTruthy();
});
});
describe('onInit callback', () => {
it('should call onInit when editor initializes', async () => {
const onInit = vi.fn();
render(<MinimalTestWrapper onInit={onInit} />);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(onInit).toHaveBeenCalled();
});
});
it('should pass editor instance to onInit', async () => {
const onInit = vi.fn();
render(<MinimalTestWrapper onInit={onInit} />);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(onInit).toHaveBeenCalledWith(
expect.objectContaining({ getDocument: expect.any(Function) }),
);
});
});
it('should not throw error when initialized with empty content', async () => {
const onInit = vi.fn();
let editorInstance: IEditor | undefined;
// This test ensures the fix for "setEditorState: the editor state is empty" error
// When editor initializes with empty/undefined content, it should not throw
const { container } = render(
<MinimalTestWrapper
onEditorReady={(e) => {
editorInstance = e;
}}
onInit={onInit}
/>,
);
await act(async () => {
await moment();
});
// Editor should initialize without error
await waitFor(() => {
expect(onInit).toHaveBeenCalled();
expect(editorInstance).toBeDefined();
});
// Editor should be rendered
expect(container.querySelector('[data-lexical-editor]')).not.toBeNull();
// Getting document should work (returns empty content)
const text = editorInstance!.getDocument('text') as unknown as string;
expect(text).toBeDefined();
});
});
describe('onContentChange callback', () => {
it('should call onContentChange when content changes via setDocument', async () => {
const onContentChange = vi.fn();
let editorInstance: IEditor | undefined;
render(
<MinimalTestWrapper
onContentChange={onContentChange}
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
// Wait for editor to be ready
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
// Change content using editor API
await act(async () => {
editorInstance!.setDocument('text', 'Hello World');
await moment();
});
await waitFor(
() => {
expect(onContentChange).toHaveBeenCalled();
},
{ timeout: 2000 },
);
});
it('should call onContentChange when markdown content is set', async () => {
const onContentChange = vi.fn();
let editorInstance: IEditor | undefined;
render(
<MinimalTestWrapper
onContentChange={onContentChange}
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
// Change content using markdown
await act(async () => {
editorInstance!.setDocument('markdown', '# Hello\n\nThis is a paragraph.');
await moment();
});
await waitFor(
() => {
expect(onContentChange).toHaveBeenCalled();
},
{ timeout: 2000 },
);
});
it('should track multiple content changes', async () => {
const onContentChange = vi.fn();
let editorInstance: IEditor | undefined;
render(
<MinimalTestWrapper
onContentChange={onContentChange}
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
// First change
await act(async () => {
editorInstance!.setDocument('text', 'First content');
await moment();
});
// Second change
await act(async () => {
editorInstance!.setDocument('text', 'Second content');
await moment();
});
// Third change
await act(async () => {
editorInstance!.setDocument('text', 'Third content');
await moment();
});
await waitFor(
() => {
// Should have multiple calls for different content changes
expect(onContentChange.mock.calls.length).toBeGreaterThanOrEqual(2);
},
{ timeout: 2000 },
);
});
});
describe('editor content methods', () => {
it('should allow getting document as markdown', async () => {
let editorInstance: IEditor | undefined;
render(
<MinimalTestWrapper
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
await act(async () => {
editorInstance!.setDocument('text', 'Test content');
await moment();
});
const markdown = editorInstance!.getDocument('markdown') as unknown as string;
expect(markdown).toContain('Test content');
});
it('should allow getting document as JSON', async () => {
let editorInstance: IEditor | undefined;
render(
<MinimalTestWrapper
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
await act(async () => {
editorInstance!.setDocument('text', 'Test content');
await moment();
});
const json = editorInstance!.getDocument('json');
expect(json).toBeDefined();
expect(typeof json).toBe('object');
});
it('should allow getting document as text', async () => {
let editorInstance: IEditor | undefined;
render(
<MinimalTestWrapper
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
await act(async () => {
editorInstance!.setDocument('markdown', '# Heading\n\nParagraph');
await moment();
});
const text = editorInstance!.getDocument('text') as unknown as string;
expect(text).toContain('Heading');
expect(text).toContain('Paragraph');
});
});
describe('lexical editor access', () => {
it('should expose getLexicalEditor method', async () => {
let editorInstance: IEditor | undefined;
render(
<MinimalTestWrapper
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
const lexicalEditor = editorInstance!.getLexicalEditor?.();
expect(lexicalEditor).toBeDefined();
});
it('should allow registering custom update listeners', async () => {
const updateListener = vi.fn();
let editorInstance: IEditor | undefined;
render(
<MinimalTestWrapper
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
const lexicalEditor = editorInstance!.getLexicalEditor?.();
expect(lexicalEditor).toBeDefined();
if (lexicalEditor) {
const unregister = lexicalEditor.registerUpdateListener(updateListener);
// Trigger an update
await act(async () => {
editorInstance!.setDocument('text', 'Updated content');
await moment();
});
expect(updateListener).toHaveBeenCalled();
// Cleanup
unregister();
}
});
});
describe('custom plugins', () => {
it('should accept custom plugins array', async () => {
const CustomPlugin = () => null;
const { container } = render(<MinimalTestWrapper plugins={[CustomPlugin]} />);
await act(async () => {
await moment();
});
// Should render without error
expect(container.querySelector('[data-lexical-editor]')).not.toBeNull();
});
it('should accept extra plugins prepended to base plugins', async () => {
const ExtraPlugin = () => null;
// Note: extraPlugins requires base plugins which need toolbar services
// We test this with minimal plugins instead
const { container } = render(<MinimalTestWrapper plugins={[ExtraPlugin]} />);
await act(async () => {
await moment();
});
// Should render without error
expect(container.querySelector('[data-lexical-editor]')).not.toBeNull();
});
});
describe('window.__editor assignment', () => {
it('should assign editor to window.__editor for debugging', async () => {
let editorInstance: IEditor | undefined;
render(
<MinimalTestWrapper
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
expect(window.__editor).toBe(editorInstance);
});
it('should clear window.__editor on unmount', async () => {
let editorInstance: IEditor | undefined;
const { unmount } = render(
<MinimalTestWrapper
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
expect(window.__editor).toBe(editorInstance);
unmount();
expect(window.__editor).toBeUndefined();
});
});
describe('callback stability', () => {
it('should maintain stable onContentChange behavior across re-renders', async () => {
const onContentChange = vi.fn();
let editorInstance: IEditor | undefined;
const { rerender } = render(
<MinimalTestWrapper
onContentChange={onContentChange}
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
// Re-render with same props
rerender(
<MinimalTestWrapper
onContentChange={onContentChange}
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
// Change content after re-render
await act(async () => {
editorInstance!.setDocument('text', 'Content after rerender');
await moment();
});
await waitFor(
() => {
expect(onContentChange).toHaveBeenCalled();
},
{ timeout: 2000 },
);
});
it('should use updated callback when onContentChange prop changes', async () => {
const firstCallback = vi.fn();
const secondCallback = vi.fn();
let editorInstance: IEditor | undefined;
const { rerender } = render(
<MinimalTestWrapper
onContentChange={firstCallback}
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
await waitFor(() => {
expect(editorInstance).toBeDefined();
});
// Change callback prop
rerender(
<MinimalTestWrapper
onContentChange={secondCallback}
onEditorReady={(e) => {
editorInstance = e;
}}
/>,
);
await act(async () => {
await moment();
});
// Trigger content change
await act(async () => {
editorInstance!.setDocument('text', 'New content');
await moment();
});
await waitFor(
() => {
// Second callback should be called
expect(secondCallback).toHaveBeenCalled();
},
{ timeout: 2000 },
);
});
});
});

View File

@@ -14,7 +14,7 @@ import {
ReactToolbarPlugin,
} from '@lobehub/editor';
import { Editor, useEditorState } from '@lobehub/editor/react';
import { memo, useEffect, useMemo } from 'react';
import { memo, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { EditorCanvasProps } from './EditorCanvas';
@@ -102,6 +102,40 @@ const InternalEditor = memo<InternalEditorProps>(
};
}, [editor]);
// Use refs for stable references across re-renders
const previousContentRef = useRef<string | undefined>(undefined);
const onContentChangeRef = useRef(onContentChange);
onContentChangeRef.current = onContentChange;
// Listen to Lexical updates directly to trigger content change
// This bypasses @lobehub/editor's onTextChange which has issues with previousContent reset
useEffect(() => {
if (!editor) return;
const lexicalEditor = editor.getLexicalEditor?.();
if (!lexicalEditor) return;
// Initialize previousContent with current content before registering listener
previousContentRef.current = JSON.stringify(editor.getDocument('text'));
const unregister = lexicalEditor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => {
// Only process when there are actual content changes
if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return;
const currentContent = JSON.stringify(editor.getDocument('text'));
if (currentContent !== previousContentRef.current) {
// Content actually changed
previousContentRef.current = currentContent;
onContentChangeRef.current?.();
}
});
return () => {
unregister();
};
}, [editor]); // Only depend on editor, use ref for onContentChange
return (
<div
onClick={(e) => {
@@ -114,7 +148,6 @@ const InternalEditor = memo<InternalEditorProps>(
editor={editor}
lineEmptyPlaceholder={finalPlaceholder}
onInit={onInit}
onTextChange={onContentChange}
placeholder={finalPlaceholder}
plugins={plugins}
slashOption={slashItems ? { items: slashItems } : undefined}

View File

@@ -180,7 +180,7 @@ describe('DocumentStore - Editor Actions', () => {
expect(mockEditor.setDocument).toHaveBeenCalledWith('json', JSON.stringify(editorData));
});
it('should set empty placeholder when no content', () => {
it('should not call setDocument when content is empty to avoid editor error', () => {
const { result } = renderHook(() => useDocumentStore());
const mockEditor = createMockEditor() as any;
@@ -196,7 +196,9 @@ describe('DocumentStore - Editor Actions', () => {
result.current.onEditorInit(mockEditor);
});
expect(mockEditor.setDocument).toHaveBeenCalledWith('markdown', ' ');
// setDocument should NOT be called for empty content
// This prevents "setEditorState: the editor state is empty" error
expect(mockEditor.setDocument).not.toHaveBeenCalled();
});
});

View File

@@ -151,12 +151,14 @@ export const createEditorSlice: StateCreator<
}
}
// Load markdown content or set empty placeholder
const mdContent = doc.content?.trim() ? doc.content : ' ';
try {
editor.setDocument('markdown', mdContent);
} catch (err) {
console.error('[DocumentStore] Failed to load markdown content:', err);
// Load markdown content if available
// Skip setDocument for empty content - let editor use its default empty state
if (doc.content?.trim()) {
try {
editor.setDocument('markdown', doc.content);
} catch (err) {
console.error('[DocumentStore] Failed to load markdown content:', err);
}
}
set({ editor });