mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-07 09:25:29 +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
|
public isFocused = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor is considered initialized after the `create` event has been emitted.
|
||||||
|
*/
|
||||||
|
public isInitialized = false
|
||||||
|
|
||||||
public extensionStorage: Record<string, any> = {}
|
public extensionStorage: Record<string, any> = {}
|
||||||
|
|
||||||
public options: EditorOptions = {
|
public options: EditorOptions = {
|
||||||
@ -111,6 +116,7 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
|
|
||||||
this.commands.focus(this.options.autofocus)
|
this.commands.focus(this.options.autofocus)
|
||||||
this.emit('create', { editor: this })
|
this.emit('create', { editor: this })
|
||||||
|
this.isInitialized = true
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { Editor as CoreEditor } from '@tiptap/core'
|
import { Editor as CoreEditor } from '@tiptap/core'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { EditorContentProps, EditorContentState } from './EditorContent.js'
|
|
||||||
import { ReactRenderer } from './ReactRenderer.js'
|
import { ReactRenderer } from './ReactRenderer.js'
|
||||||
|
|
||||||
type ContentComponent = React.Component<EditorContentProps, EditorContentState> & {
|
type ContentComponent = {
|
||||||
setRenderer(id: string, renderer: ReactRenderer): void;
|
setRenderer(id: string, renderer: ReactRenderer): void;
|
||||||
removeRenderer(id: string): void;
|
removeRenderer(id: string): void;
|
||||||
|
subscribe: (callback: () => void) => () => void;
|
||||||
|
getSnapshot: () => Record<string, React.ReactPortal>;
|
||||||
|
getServerSnapshot: () => Record<string, React.ReactPortal>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Editor extends CoreEditor {
|
export class Editor extends CoreEditor {
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import React, {
|
import React, {
|
||||||
ForwardedRef, forwardRef, HTMLProps, LegacyRef, MutableRefObject,
|
ForwardedRef, forwardRef, HTMLProps, LegacyRef, MutableRefObject,
|
||||||
} from 'react'
|
} 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 { Editor } from './Editor.js'
|
||||||
import { ReactRenderer } from './ReactRenderer.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{Object.entries(renderers).map(([key, renderer]) => {
|
{Object.values(renderers)}
|
||||||
return ReactDOM.createPortal(renderer.reactElement, renderer.element, key)
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -35,22 +47,67 @@ export interface EditorContentProps extends HTMLProps<HTMLDivElement> {
|
|||||||
innerRef?: ForwardedRef<HTMLDivElement | null>;
|
innerRef?: ForwardedRef<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorContentState {
|
function getInstance(): Exclude<Editor['contentComponent'], null> {
|
||||||
renderers: Record<string, ReactRenderer>;
|
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>
|
editorContentRef: React.RefObject<any>
|
||||||
|
|
||||||
initialized: boolean
|
initialized: boolean
|
||||||
|
|
||||||
|
unsubscribeToContentComponent?: () => void
|
||||||
|
|
||||||
constructor(props: EditorContentProps) {
|
constructor(props: EditorContentProps) {
|
||||||
super(props)
|
super(props)
|
||||||
this.editorContentRef = React.createRef()
|
this.editorContentRef = React.createRef()
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
renderers: {},
|
hasContentComponentInitialized: Boolean(props.editor?.contentComponent),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +135,27 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
|
|||||||
element,
|
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()
|
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() {
|
componentWillUnmount() {
|
||||||
const { editor } = this.props
|
const { editor } = this.props
|
||||||
|
|
||||||
@ -136,6 +178,10 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.unsubscribeToContentComponent) {
|
||||||
|
this.unsubscribeToContentComponent()
|
||||||
|
}
|
||||||
|
|
||||||
editor.contentComponent = null
|
editor.contentComponent = null
|
||||||
|
|
||||||
if (!editor.options.element.firstChild) {
|
if (!editor.options.element.firstChild) {
|
||||||
@ -158,7 +204,7 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
|
|||||||
<>
|
<>
|
||||||
<div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} />
|
<div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} />
|
||||||
{/* @ts-ignore */}
|
{/* @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>(
|
const EditorContentWithKey = forwardRef<HTMLDivElement, EditorContentProps>(
|
||||||
(props: Omit<EditorContentProps, 'innerRef'>, ref) => {
|
(props: Omit<EditorContentProps, 'innerRef'>, ref) => {
|
||||||
const key = React.useMemo(() => {
|
const key = React.useMemo(() => {
|
||||||
return Math.floor(Math.random() * 0xFFFFFFFF).toString()
|
return Math.floor(Math.random() * 0xffffffff).toString()
|
||||||
}, [props.editor])
|
}, [props.editor])
|
||||||
|
|
||||||
// Can't use JSX here because it conflicts with the type definition of Vue's JSX, so use createElement
|
// 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'
|
const Tag = props.as || 'div'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// @ts-ignore
|
||||||
<Tag
|
<Tag
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
@ -58,25 +58,23 @@ class ReactNodeView extends NodeView<
|
|||||||
this.component.displayName = capitalizeFirstChar(this.extension.name)
|
this.component.displayName = capitalizeFirstChar(this.extension.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReactNodeViewProvider: React.FunctionComponent = componentProps => {
|
const onDragStart = this.onDragStart.bind(this)
|
||||||
const Component = this.component
|
const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => {
|
||||||
const onDragStart = this.onDragStart.bind(this)
|
if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) {
|
||||||
const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => {
|
element.appendChild(this.contentDOMElement)
|
||||||
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'
|
ReactNodeViewProvider.displayName = 'ReactNodeView'
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Editor } from '@tiptap/core'
|
import { Editor } from '@tiptap/core'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
|
|
||||||
import { Editor as ExtendedEditor } from './Editor.js'
|
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 {
|
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)
|
this.editor?.contentComponent?.setRenderer(this.id, this)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user