MM-69912: let plugins bring their own editor extensions and read structured content (#37678)

* MM-69912: let plugins bring their own editor extensions and read structured content

* lint

* Claude PR feedback

* Claude AI review second pass

* gate autosave on load failure and harden json-mode parse path

* fix stale hasContentError handle and scope the error latch to load

* shorten required comment

---------

Co-authored-by: Nevyana Angelova <nevyangelova@Nevy-Macbook-16-2025.local>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
nang2049
2026-07-30 15:57:22 +03:00
committed by GitHub
co-authored by Nevyana Angelova Mattermost Build
parent d4d216e93e
commit b9cea25748
4 changed files with 602 additions and 33 deletions
@@ -0,0 +1,454 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Node} from '@tiptap/core';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
const mockCapturedConfig: {current: any} = {current: null};
// Set to make the useEditor mock emit a contentError during construction,
// matching Tiptap's render-phase emit.
const mockConstructorError: {current: Error | null} = {current: null};
jest.mock('@tiptap/react', () => {
const ReactMock = require('react') as typeof import('react');
return {
__esModule: true,
useEditor: (config: any) => {
mockCapturedConfig.current = config;
const base: any = {
isDestroyed: false,
isEmpty: true,
commands: {
clearContent: () => undefined,
focus: () => undefined,
blur: () => undefined,
insertContent: () => undefined,
},
setEditable: () => undefined,
getJSON: () => ({type: 'doc', content: [{type: 'paragraph', content: [{type: 'text', text: 'hi'}]}]}),
view: {dom: globalThis.document.createElement('div')},
};
// Mirrors the real library: getMarkdown is attached by the Markdown
// extension's onBeforeCreate, not by the contentType option.
const hasMarkdownExt = (config?.extensions ?? []).some((e: any) => (e.name || e.config?.name) === 'markdown');
if (hasMarkdownExt) {
base.getMarkdown = () => 'hi';
}
// Tiptap emits contentError synchronously inside the Editor
// constructor, i.e. during render, and constructs only once per
// mount. Consume the error so re-renders don't re-emit.
if (mockConstructorError.current) {
const error = mockConstructorError.current;
mockConstructorError.current = null;
config?.onContentError?.({error, editor: base, disableCollaboration: () => undefined});
}
return base;
},
EditorContent: () => ReactMock.createElement('div', {'data-testid': 'editor-content'}),
};
});
jest.mock('./wysiwyg_suggestion_list', () => ({
__esModule: true,
default: () => null,
}));
import WysiwygEditor from './wysiwyg_editor';
const baseProps = {
value: '',
onChange: jest.fn(),
onSubmit: jest.fn(),
channelId: 'c1',
};
const extensionNames = (): string[] => (mockCapturedConfig.current?.extensions ?? []).map((e: any) => e.name || e.config?.name);
describe('WysiwygEditor', () => {
beforeEach(() => {
mockCapturedConfig.current = null;
mockConstructorError.current = null;
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
test('markdown mode (default) registers the Markdown extension', () => {
renderWithContext(<WysiwygEditor {...baseProps}/>);
expect(extensionNames()).toContain('markdown');
expect(mockCapturedConfig.current?.contentType).toBe('markdown');
});
test('json mode omits the Markdown extension and drops the markdown contentType', () => {
renderWithContext(
<WysiwygEditor
{...baseProps}
contentType='json'
/>,
);
expect(extensionNames()).not.toContain('markdown');
expect(mockCapturedConfig.current?.contentType).toBeUndefined();
});
test('extensions prop is appended to the built-in set at mount', () => {
const CustomNode = Node.create({name: 'customNode', group: 'block'});
renderWithContext(
<WysiwygEditor
{...baseProps}
extensions={[CustomNode]}
/>,
);
const names = extensionNames();
expect(names).toContain('table'); // built-ins still present
// Position matters: consumer extensions must come last so they can
// override built-in nodes of the same name.
expect(names.indexOf('customNode')).toBe(names.length - 1);
expect(names.indexOf('customNode')).toBeGreaterThan(names.indexOf('markdown'));
});
test('extensions prop is appended in json mode too, where Markdown is absent', () => {
const CustomNode = Node.create({name: 'customNode', group: 'block'});
renderWithContext(
<WysiwygEditor
{...baseProps}
contentType='json'
extensions={[CustomNode]}
/>,
);
const names = extensionNames();
expect(names).not.toContain('markdown');
expect(names.indexOf('customNode')).toBe(names.length - 1);
});
test('onChange emits JSON in json mode', () => {
jest.useFakeTimers();
const onChange = jest.fn();
renderWithContext(
<WysiwygEditor
{...baseProps}
onChange={onChange}
contentType='json'
/>,
);
mockCapturedConfig.current?.onUpdate?.({editor: {getJSON: () => ({type: 'doc', content: []}), getMarkdown: () => ''} as any});
jest.runAllTimers();
expect(onChange).toHaveBeenCalledWith(JSON.stringify({type: 'doc', content: []}));
jest.useRealTimers();
});
test('onChange emits markdown in markdown mode', () => {
jest.useFakeTimers();
const onChange = jest.fn();
renderWithContext(
<WysiwygEditor
{...baseProps}
onChange={onChange}
/>,
);
mockCapturedConfig.current?.onUpdate?.({editor: {getJSON: () => ({}), getMarkdown: () => 'hello'} as any});
jest.runAllTimers();
expect(onChange).toHaveBeenCalledWith('hello');
jest.useRealTimers();
});
test('json mode parses a JSON string value into an object for initial content', () => {
const doc = {type: 'doc', content: [{type: 'paragraph'}]};
renderWithContext(
<WysiwygEditor
{...baseProps}
value={JSON.stringify(doc)}
contentType='json'
/>,
);
expect(mockCapturedConfig.current?.content).toEqual(doc);
});
test('json mode falls back to an empty doc when value is not valid JSON', () => {
renderWithContext(
<WysiwygEditor
{...baseProps}
value='not json'
contentType='json'
/>,
);
expect(mockCapturedConfig.current?.content).toEqual({type: 'doc', content: [{type: 'paragraph'}]});
});
test('json mode falls back to an empty doc when value parses to a non-object', () => {
renderWithContext(
<WysiwygEditor
{...baseProps}
value='"just a string"'
contentType='json'
/>,
);
expect(mockCapturedConfig.current?.content).toEqual({type: 'doc', content: [{type: 'paragraph'}]});
});
test('markdown mode leaves enableContentCheck off', () => {
renderWithContext(<WysiwygEditor {...baseProps}/>);
expect(mockCapturedConfig.current?.enableContentCheck).toBe(false);
});
test('json mode enables enableContentCheck and forwards the callback', () => {
const onContentError = jest.fn();
renderWithContext(
<WysiwygEditor
{...baseProps}
contentType='json'
onContentError={onContentError}
/>,
);
expect(mockCapturedConfig.current?.enableContentCheck).toBe(true);
const err = new Error('bad node');
mockCapturedConfig.current?.onContentError?.({error: err});
expect(onContentError).toHaveBeenCalledWith(err);
});
test('a post-mount content error forwards but does not latch hasContentError', () => {
const onContentError = jest.fn();
const ref = React.createRef<React.ComponentRef<typeof WysiwygEditor>>();
renderWithContext(
<WysiwygEditor
{...baseProps}
ref={ref}
value='{"type":"doc","content":[]}'
contentType='json'
onContentError={onContentError}
/>,
);
expect(ref.current!.hasContentError()).toBe(false);
const err = new Error('bad insert');
mockCapturedConfig.current?.onContentError?.({error: err});
expect(onContentError).toHaveBeenCalledWith(err);
// Latching here would permanently stall a consumer's autosave loop that
// started from a clean load.
expect(ref.current!.hasContentError()).toBe(false);
});
test('a content error emitted during construction is deferred so a consumer can setState', () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
const seen: Array<Error | null> = [];
const constructorError = new Error('schema mismatch');
mockConstructorError.current = constructorError;
// Models the real consumer: a parent that records the error in state.
// If the editor emits during its own render, this setState happens while
// rendering a different component and React logs an error.
const Parent = () => {
const [err, setErr] = React.useState<Error | null>(null);
seen.push(err);
return (
<WysiwygEditor
{...baseProps}
value='{"type":"doc","content":[]}'
contentType='json'
onContentError={setErr}
/>
);
};
renderWithContext(<Parent/>);
expect(seen[seen.length - 1]).toBe(constructorError);
expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();
});
test('hasContentError latches when the initial load fails during construction', () => {
const ref = React.createRef<React.ComponentRef<typeof WysiwygEditor>>();
mockConstructorError.current = new Error('schema mismatch');
renderWithContext(
<WysiwygEditor
{...baseProps}
ref={ref}
value='{"type":"doc","content":[]}'
contentType='json'
/>,
);
expect(ref.current!.hasContentError()).toBe(true);
});
test('contentType is frozen at mount and ignores a later prop change', () => {
const onChange = jest.fn();
const {rerender} = renderWithContext(
<WysiwygEditor
{...baseProps}
onChange={onChange}
contentType='json'
/>,
);
rerender(
<WysiwygEditor
{...baseProps}
onChange={onChange}
contentType='markdown'
/>,
);
// Still json mode: paste stays short-circuited and updates stay JSON.
expect(mockCapturedConfig.current?.editorProps?.handlePaste?.({}, {})).toBe(false);
jest.useFakeTimers();
mockCapturedConfig.current?.onUpdate?.({editor: {getJSON: () => ({type: 'doc'})}});
jest.runAllTimers();
expect(onChange).toHaveBeenCalledWith(JSON.stringify({type: 'doc'}));
});
test('json mode reports a parse error via onContentError when value is unparseable', async () => {
const onContentError = jest.fn();
renderWithContext(
<WysiwygEditor
{...baseProps}
value='not json'
contentType='json'
onContentError={onContentError}
/>,
);
expect(onContentError).toHaveBeenCalledTimes(1);
expect(onContentError.mock.calls[0][0]).toBeInstanceOf(Error);
});
test('handlePaste short-circuits in json mode; markdown mode still handles pastes', () => {
const mkEvent = () => ({
preventDefault: jest.fn(),
clipboardData: {
getData: (type: string) => (type === 'text/plain' ? '# heading' : ''),
},
}) as any;
renderWithContext(
<WysiwygEditor
{...baseProps}
contentType='json'
/>,
);
expect(mockCapturedConfig.current?.editorProps?.handlePaste?.({} as any, mkEvent())).toBe(false);
mockCapturedConfig.current = null;
renderWithContext(<WysiwygEditor {...baseProps}/>);
const result = mockCapturedConfig.current?.editorProps?.handlePaste?.({} as any, mkEvent());
expect(result).toBe(true);
});
test('getEditor() on the handle returns the underlying Tiptap Editor instance', () => {
const ref = React.createRef<React.ComponentRef<typeof WysiwygEditor>>();
renderWithContext(
<WysiwygEditor
{...baseProps}
ref={ref}
/>,
);
const editor = ref.current!.getEditor();
expect(editor).not.toBeNull();
expect(typeof (editor as any).getJSON).toBe('function');
});
test.each([
['null', 'null'],
['array', '[1,2,3]'],
['number', '42'],
])('json mode falls back to empty doc when value parses to %s', (_label, raw) => {
const onContentError = jest.fn();
renderWithContext(
<WysiwygEditor
{...baseProps}
value={raw}
contentType='json'
onContentError={onContentError}
/>,
);
expect(mockCapturedConfig.current?.content).toEqual({type: 'doc', content: [{type: 'paragraph'}]});
expect(onContentError).toHaveBeenCalledTimes(1);
});
test('json mode does not throw when consumer omits onContentError for a bad value', () => {
expect(() => {
renderWithContext(
<WysiwygEditor
{...baseProps}
value='not json'
contentType='json'
/>,
);
}).not.toThrow();
});
test('handle.hasContentError() reflects load failure', async () => {
const ref = React.createRef<React.ComponentRef<typeof WysiwygEditor>>();
renderWithContext(
<WysiwygEditor
{...baseProps}
value='not json'
contentType='json'
ref={ref}
/>,
);
expect(ref.current!.hasContentError()).toBe(true);
});
test('handle.hasContentError() is false after a clean json load', () => {
const ref = React.createRef<React.ComponentRef<typeof WysiwygEditor>>();
renderWithContext(
<WysiwygEditor
{...baseProps}
value={JSON.stringify({type: 'doc', content: [{type: 'paragraph'}]})}
contentType='json'
ref={ref}
/>,
);
expect(ref.current!.hasContentError()).toBe(false);
});
});
@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Extensions} from '@tiptap/core';
import {Extension} from '@tiptap/core';
import {CodeBlockLowlight} from '@tiptap/extension-code-block-lowlight';
import Link from '@tiptap/extension-link';
@@ -18,8 +19,9 @@ import {EditorContent, useEditor} from '@tiptap/react';
import type {Editor} from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import emojiRegex from 'emoji-regex';
import isPlainObject from 'lodash/isPlainObject';
import {common, createLowlight} from 'lowlight';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {useDispatch} from 'react-redux';
import {editLatestPost} from 'actions/views/create_comment';
@@ -91,11 +93,15 @@ export type WysiwygEditorHandle = {
focus: () => void;
blur: () => void;
getInputBox: () => HTMLElement | null;
// True when the initial `value` failed to load in json mode. See
// PublishedWysiwygEditorHandle for the autosave-gating contract.
hasContentError: () => boolean;
};
type Props = {
value: string;
onChange: (markdown: string) => void;
onChange: (content: string) => void;
onSubmit: () => void;
onFocus?: () => void;
onBlur?: () => void;
@@ -107,6 +113,26 @@ type Props = {
useCtrlSend?: boolean;
sendCodeBlockOnCtrlEnter?: boolean;
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
contentType?: 'markdown' | 'json';
extensions?: Extensions;
onContentError?: (error: Error) => void;
};
const EMPTY_JSON_DOC = {type: 'doc', content: [{type: 'paragraph'}]} as const;
const parseJsonModeContent = (value: string): {content: string | Record<string, unknown>; error: Error | null} => {
if (!value) {
return {content: EMPTY_JSON_DOC, error: null};
}
try {
const parsed = JSON.parse(value);
if (isPlainObject(parsed)) {
return {content: parsed as Record<string, unknown>, error: null};
}
return {content: EMPTY_JSON_DOC, error: new Error('Invalid JSON content: expected an object doc')};
} catch (err) {
return {content: EMPTY_JSON_DOC, error: err instanceof Error ? err : new Error('Invalid JSON content')};
}
};
const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
@@ -123,7 +149,13 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
useCtrlSend = false,
sendCodeBlockOnCtrlEnter = false,
onKeyDown,
contentType = 'markdown',
extensions: extraExtensions,
onContentError,
}, ref) => {
// Frozen: the Tiptap schema is fixed at construction, so a mid-flight prop
// swap would desync the paste and update handlers from the actual editor.
const jsonMode = useRef(contentType === 'json').current;
const dispatch = useDispatch();
const channelIdRef = useLatest(channelId);
const rootIdRef = useLatest(rootId);
@@ -144,6 +176,11 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
}, SERIALIZE_DEBOUNCE_MS);
const handleUpdate = useCallback(({editor}: {editor: Editor}) => {
if (jsonMode) {
debouncedOnChange(JSON.stringify(editor.getJSON()));
return;
}
// Strip &nbsp; artifacts the @tiptap/markdown serializer leaves around
// empty paragraphs at doc start/end.
const md = editor.getMarkdown().trimEnd().
@@ -151,38 +188,89 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
replace(/\n\n&nbsp;$/g, '').
replace(/^&nbsp;$/, '');
debouncedOnChange(md);
}, [debouncedOnChange]);
}, [debouncedOnChange, jsonMode]);
const baseExtensions: Extensions = [
StarterKit.configure({
heading: {levels: [1, 2, 3, 4, 5, 6]},
codeBlock: false,
link: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
Link.configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
}),
Placeholder.configure({
placeholder: () => placeholderRef.current,
showOnlyCurrent: true,
}),
Table.configure({resizable: false, cellMinWidth: 80}),
TableRow,
TableCell,
TableHeader,
EmojiDecorations,
];
if (!jsonMode) {
baseExtensions.push(Markdown.configure({markedOptions: {gfm: true}}));
}
if (extraExtensions?.length) {
baseExtensions.push(...extraExtensions);
}
const onContentErrorRef = useLatest(onContentError);
const mountedRef = useRef(false);
const pendingErrorRef = useRef<Error | null>(null);
// A ref, not state: the handle must report this synchronously, and a
// consumer reading it from its own onContentError handler runs before any
// re-render would land.
const hasContentErrorRef = useRef(false);
const [initialContent] = useState<string | Record<string, unknown>>(() => {
if (!jsonMode) {
return value;
}
const {content, error} = parseJsonModeContent(value);
if (error) {
hasContentErrorRef.current = true;
pendingErrorRef.current = error;
}
return content;
});
const captureContentError = (error: Error) => {
// Post-mount errors come from consumer-driven commands, not the initial
// load, so they forward without latching hasContentError.
if (mountedRef.current) {
onContentErrorRef.current?.(error);
return;
}
hasContentErrorRef.current = true;
pendingErrorRef.current = error;
};
useEffect(() => {
mountedRef.current = true;
if (pendingErrorRef.current) {
onContentErrorRef.current?.(pendingErrorRef.current);
pendingErrorRef.current = null;
}
}, []);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {levels: [1, 2, 3, 4, 5, 6]},
codeBlock: false,
link: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
Link.configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
}),
Placeholder.configure({
placeholder: () => placeholderRef.current,
showOnlyCurrent: true,
}),
Table.configure({resizable: false, cellMinWidth: 80}),
TableRow,
TableCell,
TableHeader,
Markdown.configure({
markedOptions: {gfm: true},
}),
EmojiDecorations,
],
content: value,
contentType: 'markdown',
extensions: baseExtensions,
content: initialContent,
contentType: jsonMode ? undefined : 'markdown',
enableContentCheck: jsonMode,
// Tiptap emits this from the Editor constructor, which useEditor runs
// during render — hence the buffering in captureContentError.
onContentError: ({error}) => captureContentError(error),
editable: !disabled,
editorProps: {
attributes: {
@@ -191,6 +279,10 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
...(disabled ? {'aria-disabled': 'true', 'data-disabled': 'true'} : {'aria-disabled': 'false'}),
},
handlePaste: (_view, event) => {
if (jsonMode) {
return false;
}
const text = event.clipboardData?.getData('text/plain');
if (!text) {
return false;
@@ -411,6 +503,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
}
return null;
},
hasContentError: () => hasContentErrorRef.current,
}), []);
const lastValueRef = useRef(value);
@@ -22,6 +22,7 @@ jest.mock('components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor', () =>
blur: () => {},
getInputBox: () => null,
getEditor: () => null,
hasContentError: () => false,
}));
return null;
}),
@@ -89,6 +90,8 @@ describe('WysiwygEditor handle forwarding', () => {
expect(typeof handle!.focus).toBe('function');
expect(typeof handle!.blur).toBe('function');
expect(typeof handle!.getInputBox).toBe('function');
expect(typeof handle!.getEditor).toBe('function');
expect(typeof handle!.hasContentError).toBe('function');
});
});
@@ -21,7 +21,7 @@ export type ActionResult<Data = unknown, Error = unknown> = {
export type WysiwygEditorProps = {
value: string;
onChange: (markdown: string) => void;
onChange: (content: string) => void;
onSubmit: () => void;
onFocus?: () => void;
onBlur?: () => void;
@@ -33,6 +33,16 @@ export type WysiwygEditorProps = {
useCtrlSend?: boolean;
sendCodeBlockOnCtrlEnter?: boolean;
onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void;
// 'json' reads and emits stringified ProseMirror JSON. Mount-only.
contentType?: 'markdown' | 'json';
// Mount-only. `any[]` so consumers don't need `@tiptap/core` transitively.
extensions?: any[];
// Any content error in 'json' mode, for the editor's lifetime. See
// hasContentError() for the autosave-gating contract.
onContentError?: (error: Error) => void;
};
export type SuggestionListProps = {
@@ -121,6 +131,15 @@ export type PublishedWysiwygEditorHandle = {
focus: () => void;
blur: () => void;
getInputBox: () => HTMLElement | null;
// Null until the mount effect runs, so a useLayoutEffect can still miss it.
// In 'json' mode use getJSON(); getMarkdown() isn't attached.
getEditor: () => any;
// True when the initial `value` failed to load in 'json' mode. Autosaving
// consumers must gate the first onChange on this, or the empty fallback
// overwrites the source. Load-only, so it can't stall a healthy session.
hasContentError: () => boolean;
};
export type PublishedFormattingBarHandle = {