diff --git a/.changeset/rotten-beers-protect.md b/.changeset/rotten-beers-protect.md new file mode 100644 index 000000000..8f0ef7b56 --- /dev/null +++ b/.changeset/rotten-beers-protect.md @@ -0,0 +1,12 @@ +--- +"@tiptap/react": minor +"@tiptap/core": minor +--- + +This PR significantly improves the performance of React NodeViews in a couple of ways: + +- It now uses useSyncExternalStore to synchronize changes between React & the editor instance +- It dramatically reduces the number of re-renders by re-using instances of React portals that have already been initialized and unaffected by the change made in the editor + +We were seeing performance problems with React NodeViews because a change to one of them would cause a re-render to all instances of node views. For an application that heavily relies on node views in React, this was quite expensive. +This should dramatically cut down on the number of instances that have to re-render, and, making each of those re-renders much less costly. diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index cd04cafc2..fc5fb37bd 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -57,6 +57,11 @@ export class Editor extends EventEmitter { public isFocused = false + /** + * The editor is considered initialized after the `create` event has been emitted. + */ + public isInitialized = false + public extensionStorage: Record = {} public options: EditorOptions = { @@ -111,6 +116,7 @@ export class Editor extends EventEmitter { this.commands.focus(this.options.autofocus) this.emit('create', { editor: this }) + this.isInitialized = true }, 0) } diff --git a/packages/react/src/Editor.ts b/packages/react/src/Editor.ts index 24f2c0f52..572c43520 100644 --- a/packages/react/src/Editor.ts +++ b/packages/react/src/Editor.ts @@ -1,12 +1,14 @@ import { Editor as CoreEditor } from '@tiptap/core' import React from 'react' -import { EditorContentProps, EditorContentState } from './EditorContent.js' import { ReactRenderer } from './ReactRenderer.js' -type ContentComponent = React.Component & { +type ContentComponent = { setRenderer(id: string, renderer: ReactRenderer): void; removeRenderer(id: string): void; + subscribe: (callback: () => void) => () => void; + getSnapshot: () => Record; + getServerSnapshot: () => Record; } export class Editor extends CoreEditor { diff --git a/packages/react/src/EditorContent.tsx b/packages/react/src/EditorContent.tsx index b06b83fa1..68353911a 100644 --- a/packages/react/src/EditorContent.tsx +++ b/packages/react/src/EditorContent.tsx @@ -1,7 +1,8 @@ import React, { ForwardedRef, forwardRef, HTMLProps, LegacyRef, MutableRefObject, } from 'react' -import ReactDOM, { flushSync } from 'react-dom' +import ReactDOM from 'react-dom' +import { useSyncExternalStore } from 'use-sync-external-store/shim' import { Editor } from './Editor.js' import { ReactRenderer } from './ReactRenderer.js' @@ -20,12 +21,23 @@ const mergeRefs = ( } } -const Portals: React.FC<{ renderers: Record }> = ({ renderers }) => { +/** + * This component renders all of the editor's node views. + */ +const Portals: React.FC<{ contentComponent: Exclude }> = ({ + contentComponent, +}) => { + // For performance reasons, we render the node view portals on state changes only + const renderers = useSyncExternalStore( + contentComponent.subscribe, + contentComponent.getSnapshot, + contentComponent.getServerSnapshot, + ) + + // This allows us to directly render the portals without any additional wrapper return ( <> - {Object.entries(renderers).map(([key, renderer]) => { - return ReactDOM.createPortal(renderer.reactElement, renderer.element, key) - })} + {Object.values(renderers)} ) } @@ -35,22 +47,67 @@ export interface EditorContentProps extends HTMLProps { innerRef?: ForwardedRef; } -export interface EditorContentState { - renderers: Record; +function getInstance(): Exclude { + const subscribers = new Set<() => void>() + let renderers: Record = {} + + return { + /** + * Subscribe to the editor instance's changes. + */ + subscribe(callback: () => void) { + subscribers.add(callback) + return () => { + subscribers.delete(callback) + } + }, + getSnapshot() { + return renderers + }, + getServerSnapshot() { + return renderers + }, + /** + * Adds a new NodeView Renderer to the editor. + */ + setRenderer(id: string, renderer: ReactRenderer) { + renderers = { + ...renderers, + [id]: ReactDOM.createPortal(renderer.reactElement, renderer.element, id), + } + + subscribers.forEach(subscriber => subscriber()) + }, + /** + * Removes a NodeView Renderer from the editor. + */ + removeRenderer(id: string) { + const nextRenderers = { ...renderers } + + delete nextRenderers[id] + renderers = nextRenderers + subscribers.forEach(subscriber => subscriber()) + }, + } } -export class PureEditorContent extends React.Component { +export class PureEditorContent extends React.Component< + EditorContentProps, + { hasContentComponentInitialized: boolean } +> { editorContentRef: React.RefObject initialized: boolean + unsubscribeToContentComponent?: () => void + constructor(props: EditorContentProps) { super(props) this.editorContentRef = React.createRef() this.initialized = false this.state = { - renderers: {}, + hasContentComponentInitialized: Boolean(props.editor?.contentComponent), } } @@ -78,7 +135,27 @@ export class PureEditorContent extends React.Component { + this.setState(prevState => { + if (!prevState.hasContentComponentInitialized) { + return { + hasContentComponentInitialized: true, + } + } + return prevState + }) + + // Unsubscribe to previous content component + if (this.unsubscribeToContentComponent) { + this.unsubscribeToContentComponent() + } + }) + } editor.createNodeViews() @@ -86,41 +163,6 @@ export class PureEditorContent extends React.Component void) { - // Avoid calling flushSync until the editor is initialized. - // Initialization happens during the componentDidMount or componentDidUpdate - // lifecycle methods, and React doesn't allow calling flushSync from inside - // a lifecycle method. - if (this.initialized) { - flushSync(fn) - } else { - fn() - } - } - - setRenderer(id: string, renderer: ReactRenderer) { - this.maybeFlushSync(() => { - this.setState(({ renderers }) => ({ - renderers: { - ...renderers, - [id]: renderer, - }, - })) - }) - } - - removeRenderer(id: string) { - this.maybeFlushSync(() => { - this.setState(({ renderers }) => { - const nextRenderers = { ...renderers } - - delete nextRenderers[id] - - return { renderers: nextRenderers } - }) - }) - } - componentWillUnmount() { const { editor } = this.props @@ -136,6 +178,10 @@ export class PureEditorContent extends React.Component
{/* @ts-ignore */} - + {editor?.contentComponent && } ) } @@ -168,7 +214,7 @@ export class PureEditorContent extends React.Component( (props: Omit, ref) => { const key = React.useMemo(() => { - return Math.floor(Math.random() * 0xFFFFFFFF).toString() + return Math.floor(Math.random() * 0xffffffff).toString() }, [props.editor]) // Can't use JSX here because it conflicts with the type definition of Vue's JSX, so use createElement diff --git a/packages/react/src/NodeViewWrapper.tsx b/packages/react/src/NodeViewWrapper.tsx index 5fc36cfd9..5ace686a2 100644 --- a/packages/react/src/NodeViewWrapper.tsx +++ b/packages/react/src/NodeViewWrapper.tsx @@ -12,6 +12,7 @@ export const NodeViewWrapper: React.FC = React.forwardRef( const Tag = props.as || 'div' return ( + // @ts-ignore { - const Component = this.component - const onDragStart = this.onDragStart.bind(this) - const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => { - if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) { - element.appendChild(this.contentDOMElement) - } + const onDragStart = this.onDragStart.bind(this) + const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => { + if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) { + element.appendChild(this.contentDOMElement) } - - return ( - <> - {/* @ts-ignore */} - - {/* @ts-ignore */} - - - - ) } + const context = { onDragStart, nodeViewContentRef } + const Component = this.component + // For performance reasons, we memoize the provider component + // And all of the things it requires are declared outside of the component, so it doesn't need to re-render + const ReactNodeViewProvider: React.FunctionComponent = React.memo(componentProps => { + return ( + + {React.createElement(Component, componentProps)} + + ) + }) ReactNodeViewProvider.displayName = 'ReactNodeView' diff --git a/packages/react/src/ReactRenderer.tsx b/packages/react/src/ReactRenderer.tsx index 4fde17bca..9d39c69f0 100644 --- a/packages/react/src/ReactRenderer.tsx +++ b/packages/react/src/ReactRenderer.tsx @@ -1,5 +1,6 @@ import { Editor } from '@tiptap/core' import React from 'react' +import { flushSync } from 'react-dom' import { Editor as ExtendedEditor } from './Editor.js' @@ -121,7 +122,15 @@ export class ReactRenderer { }) } - this.render() + if (this.editor.isInitialized) { + // On first render, we need to flush the render synchronously + // Renders afterwards can be async, but this fixes a cursor positioning issue + flushSync(() => { + this.render() + }) + } else { + this.render() + } } render(): void { @@ -134,7 +143,7 @@ export class ReactRenderer { } } - this.reactElement = + this.reactElement = React.createElement(Component, props) this.editor?.contentComponent?.setRenderer(this.id, this) }