fix(react): resolves React NodeView performance issues (#5273)

This commit is contained in:
Nick Perez 2024-08-09 07:56:19 +02:00 committed by GitHub
parent 86a855355f
commit e31673d347
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 142 additions and 68 deletions

View File

@ -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.

View File

@ -57,6 +57,11 @@ export class Editor extends EventEmitter<EditorEvents> {
public isFocused = false
/**
* The editor is considered initialized after the `create` event has been emitted.
*/
public isInitialized = false
public extensionStorage: Record<string, any> = {}
public options: EditorOptions = {
@ -111,6 +116,7 @@ export class Editor extends EventEmitter<EditorEvents> {
this.commands.focus(this.options.autofocus)
this.emit('create', { editor: this })
this.isInitialized = true
}, 0)
}

View File

@ -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<EditorContentProps, EditorContentState> & {
type ContentComponent = {
setRenderer(id: string, renderer: ReactRenderer): void;
removeRenderer(id: string): void;
subscribe: (callback: () => void) => () => void;
getSnapshot: () => Record<string, React.ReactPortal>;
getServerSnapshot: () => Record<string, React.ReactPortal>;
}
export class Editor extends CoreEditor {

View File

@ -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 = <T extends HTMLDivElement>(
}
}
const Portals: React.FC<{ renderers: Record<string, ReactRenderer> }> = ({ renderers }) => {
/**
* This component renders all of the editor's node views.
*/
const Portals: React.FC<{ contentComponent: Exclude<Editor['contentComponent'], null> }> = ({
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<HTMLDivElement> {
innerRef?: ForwardedRef<HTMLDivElement | null>;
}
export interface EditorContentState {
renderers: Record<string, ReactRenderer>;
function getInstance(): Exclude<Editor['contentComponent'], null> {
const subscribers = new Set<() => void>()
let renderers: Record<string, React.ReactPortal> = {}
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<EditorContentProps, EditorContentState> {
export class PureEditorContent extends React.Component<
EditorContentProps,
{ hasContentComponentInitialized: boolean }
> {
editorContentRef: React.RefObject<any>
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<EditorContentProps, Edito
element,
})
editor.contentComponent = this
editor.contentComponent = getInstance()
// Has the content component been initialized?
if (!this.state.hasContentComponentInitialized) {
// Subscribe to the content component
this.unsubscribeToContentComponent = editor.contentComponent.subscribe(() => {
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<EditorContentProps, Edito
}
}
maybeFlushSync(fn: () => 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<EditorContentProps, Edito
})
}
if (this.unsubscribeToContentComponent) {
this.unsubscribeToContentComponent()
}
editor.contentComponent = null
if (!editor.options.element.firstChild) {
@ -158,7 +204,7 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
<>
<div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} />
{/* @ts-ignore */}
<Portals renderers={this.state.renderers} />
{editor?.contentComponent && <Portals contentComponent={editor.contentComponent} />}
</>
)
}
@ -168,7 +214,7 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
const EditorContentWithKey = forwardRef<HTMLDivElement, EditorContentProps>(
(props: Omit<EditorContentProps, 'innerRef'>, 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

View File

@ -12,6 +12,7 @@ export const NodeViewWrapper: React.FC<NodeViewWrapperProps> = React.forwardRef(
const Tag = props.as || 'div'
return (
// @ts-ignore
<Tag
{...props}
ref={ref}

View File

@ -58,25 +58,23 @@ class ReactNodeView extends NodeView<
this.component.displayName = capitalizeFirstChar(this.extension.name)
}
const ReactNodeViewProvider: React.FunctionComponent = componentProps => {
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 */}
<ReactNodeViewContext.Provider value={{ onDragStart, nodeViewContentRef }}>
{/* @ts-ignore */}
<Component {...componentProps} />
</ReactNodeViewContext.Provider>
</>
)
}
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 (
<ReactNodeViewContext.Provider value={context}>
{React.createElement(Component, componentProps)}
</ReactNodeViewContext.Provider>
)
})
ReactNodeViewProvider.displayName = 'ReactNodeView'

View File

@ -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<R = unknown, P = unknown> {
})
}
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<R = unknown, P = unknown> {
}
}
this.reactElement = <Component {...props } />
this.reactElement = React.createElement(Component, props)
this.editor?.contentComponent?.setRenderer(this.id, this)
}