Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 8 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/rotten-beers-protect.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/core/src/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
}

Expand Down
6 changes: 4 additions & 2 deletions packages/react/src/Editor.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
140 changes: 93 additions & 47 deletions packages/react/src/EditorContent.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)}
</>
)
}
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -78,49 +135,34 @@ 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()

this.initialized = true
}
}

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

Expand All @@ -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) {
Expand All @@ -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} />}
</>
)
}
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/NodeViewWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const NodeViewWrapper: React.FC<NodeViewWrapperProps> = React.forwardRef(
const Tag = props.as || 'div'

return (
// @ts-ignore
<Tag
{...props}
ref={ref}
Expand Down
30 changes: 14 additions & 16 deletions packages/react/src/ReactNodeViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

}
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 (
<>
{/* @ts-ignore */}
<ReactNodeViewContext.Provider value={{ onDragStart, nodeViewContentRef }}>
{/* @ts-ignore */}
<Component {...componentProps} />
</ReactNodeViewContext.Provider>
</>
<ReactNodeViewContext.Provider value={context}>
{React.createElement(Component, componentProps)}
</ReactNodeViewContext.Provider>
)
}
})

ReactNodeViewProvider.displayName = 'ReactNodeView'

Expand Down
13 changes: 11 additions & 2 deletions packages/react/src/ReactRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -121,7 +122,15 @@ export class ReactRenderer<R = unknown, P = unknown> {
})
}

this.render()
if (this.editor.isInitialized) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this call flushSync every time setRenderer was called after the editor has initialized?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in the constructor function, for the initial render.

Before this change, we were calling flushSync on every setRenderer call (with maybeFlushSync).

With this change, it will only flush sync on the first render and then every update after will be handled with a useSyncExternalStore (i.e. letting React "pick up" the changes whenever it feels like it)

// 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 {
Expand All @@ -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)
}
Expand Down