mirror of
https://github.com/ueberdosis/tiptap.git
synced 2024-11-23 19:19:03 +08:00
fix(react): resolves React NodeView performance issues (#5273)
This commit is contained in:
parent
86a855355f
commit
e31673d347
12
.changeset/rotten-beers-protect.md
Normal file
12
.changeset/rotten-beers-protect.md
Normal 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.
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -12,6 +12,7 @@ export const NodeViewWrapper: React.FC<NodeViewWrapperProps> = React.forwardRef(
|
||||
const Tag = props.as || 'div'
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Tag
|
||||
{...props}
|
||||
ref={ref}
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user