fix(react): optimize useEditor and useEditorState to reduce number of instances created while being performant #5432 (#5445)

This commit is contained in:
Nick Perez 2024-08-05 17:46:19 +02:00 committed by GitHub
parent 84ebd511d2
commit 7c8889a2a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 601 additions and 393 deletions

View File

@ -0,0 +1,12 @@
---
"@tiptap/react": patch
---
Optimize `useEditor` and `useEditorState` to reduce number of instances created while still being performant #5432
The core of this change is two-fold:
- have the effect run on every render (i.e. without a dep array)
- schedule destruction of instances, but bail on the actual destruction if the instance was still mounted and a new instance had not been created yet
It should plug a memory leak, where editor instances could be created but not cleaned up in strict mode.
As well as fixing a bug where a re-render, with deps, was not applying new options that were set on `useEditor`.

View File

@ -8,6 +8,15 @@ module.exports = {
node: true,
},
overrides: [
{
files: [
'./**/*.ts',
'./**/*.tsx',
'./**/*.js',
'./**/*.jsx',
],
extends: ['plugin:react-hooks/recommended'],
},
{
files: [
'./**/*.ts',

View File

@ -8,10 +8,6 @@ import StarterKit from '@tiptap/starter-kit'
import React, { useCallback } from 'react'
const MenuBar = ({ editor }) => {
if (!editor) {
return null
}
const onCutToStart = useCallback(() => {
editor.chain().cut({ from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos }, 1).run()
}, [editor])
@ -20,6 +16,10 @@ const MenuBar = ({ editor }) => {
editor.chain().cut({ from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos }, editor.state.doc.nodeSize - 2).run()
}, [editor])
if (!editor) {
return null
}
return (
<div className="control-group">
<div className="button-group">

View File

@ -62,7 +62,7 @@ function EditorInstance({ shouldOptimizeRendering }) {
})
return (
<>
<div>
<div className="control-group">
<div>Number of renders: <span id="render-count">{countRenderRef.current}</span></div>
</div>
@ -89,12 +89,13 @@ function EditorInstance({ shouldOptimizeRendering }) {
</BubbleMenu>
)}
<EditorContent editor={editor} />
</>
</div>
)
}
const EditorControls = () => {
const [shouldOptimizeRendering, setShouldOptimizeRendering] = React.useState(true)
const [rendered, setRendered] = React.useState(true)
return (
<>
@ -123,8 +124,9 @@ const EditorControls = () => {
Render every transaction (default behavior)
</label>
</div>
<button onClick={() => setRendered(a => !a)}>Toggle rendered</button>
</div>
<EditorInstance shouldOptimizeRendering={shouldOptimizeRendering} />
{rendered && <EditorInstance shouldOptimizeRendering={shouldOptimizeRendering} />}
</>
)
}

View File

@ -7,6 +7,7 @@ import React, { useEffect, useState } from 'react'
export default () => {
const [editable, setEditable] = useState(false)
const editor = useEditor({
shouldRerenderOnTransaction: false,
editable,
content: `
<p>

479
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -58,6 +58,7 @@
"eslint-plugin-cypress": "^2.15.2",
"eslint-plugin-html": "^6.2.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-vue": "^9.27.0",
"husky": "^8.0.3",

View File

@ -1,8 +1,13 @@
import { EditorOptions } from '@tiptap/core'
import {
DependencyList, MutableRefObject,
useDebugValue, useEffect, useRef, useState,
DependencyList,
MutableRefObject,
useDebugValue,
useEffect,
useRef,
useState,
} from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim'
import { Editor } from './Editor.js'
import { useEditorState } from './useEditorState.js'
@ -31,22 +36,241 @@ export type UseEditorOptions = Partial<EditorOptions> & {
};
/**
* Create a new editor instance. And attach event listeners.
* This class handles the creation, destruction, and re-creation of the editor instance.
*/
function createEditor(options: MutableRefObject<UseEditorOptions>): Editor {
const editor = new Editor(options.current)
class EditorInstanceManager {
/**
* The current editor instance.
*/
private editor: Editor | null = null
editor.on('beforeCreate', (...args) => options.current.onBeforeCreate?.(...args))
editor.on('blur', (...args) => options.current.onBlur?.(...args))
editor.on('create', (...args) => options.current.onCreate?.(...args))
editor.on('destroy', (...args) => options.current.onDestroy?.(...args))
editor.on('focus', (...args) => options.current.onFocus?.(...args))
editor.on('selectionUpdate', (...args) => options.current.onSelectionUpdate?.(...args))
editor.on('transaction', (...args) => options.current.onTransaction?.(...args))
editor.on('update', (...args) => options.current.onUpdate?.(...args))
editor.on('contentError', (...args) => options.current.onContentError?.(...args))
/**
* The most recent options to apply to the editor.
*/
private options: MutableRefObject<UseEditorOptions>
return editor
/**
* The subscriptions to notify when the editor instance
* has been created or destroyed.
*/
private subscriptions = new Set<() => void>()
/**
* A timeout to destroy the editor if it was not mounted within a time frame.
*/
private scheduledDestructionTimeout: ReturnType<typeof setTimeout> | undefined
/**
* Whether the editor has been mounted.
*/
private isComponentMounted = false
/**
* The most recent dependencies array.
*/
private previousDeps: DependencyList | null = null
/**
* The unique instance ID. This is used to identify the editor instance. And will be re-generated for each new instance.
*/
public instanceId = ''
constructor(options: MutableRefObject<UseEditorOptions>) {
this.options = options
this.subscriptions = new Set<() => void>()
this.setEditor(this.getInitialEditor())
this.getEditor = this.getEditor.bind(this)
this.getServerSnapshot = this.getServerSnapshot.bind(this)
this.subscribe = this.subscribe.bind(this)
this.refreshEditorInstance = this.refreshEditorInstance.bind(this)
this.scheduleDestroy = this.scheduleDestroy.bind(this)
this.onRender = this.onRender.bind(this)
this.createEditor = this.createEditor.bind(this)
}
private setEditor(editor: Editor | null) {
this.editor = editor
this.instanceId = Math.random().toString(36).slice(2, 9)
// Notify all subscribers that the editor instance has been created
this.subscriptions.forEach(cb => cb())
}
private getInitialEditor() {
if (this.options.current.immediatelyRender === undefined) {
if (isSSR || isNext) {
// TODO in the next major release, we should throw an error here
if (isDev) {
/**
* Throw an error in development, to make sure the developer is aware that tiptap cannot be SSR'd
* and that they need to set `immediatelyRender` to `false` to avoid hydration mismatches.
*/
console.warn(
'Tiptap Error: SSR has been detected, please set `immediatelyRender` explicitly to `false` to avoid hydration mismatches.',
)
}
// Best faith effort in production, run the code in the legacy mode to avoid hydration mismatches and errors in production
return null
}
// Default to immediately rendering when client-side rendering
return this.createEditor()
}
if (this.options.current.immediatelyRender && isSSR && isDev) {
// Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches.
throw new Error(
'Tiptap Error: SSR has been detected, and `immediatelyRender` has been set to `true` this is an unsupported configuration that may result in errors, explicitly set `immediatelyRender` to `false` to avoid hydration mismatches.',
)
}
if (this.options.current.immediatelyRender) {
return this.createEditor()
}
return null
}
/**
* Create a new editor instance. And attach event listeners.
*/
private createEditor(): Editor {
const editor = new Editor(this.options.current)
// Always call the most recent version of the callback function by default
editor.on('beforeCreate', (...args) => this.options.current.onBeforeCreate?.(...args))
editor.on('blur', (...args) => this.options.current.onBlur?.(...args))
editor.on('create', (...args) => this.options.current.onCreate?.(...args))
editor.on('destroy', (...args) => this.options.current.onDestroy?.(...args))
editor.on('focus', (...args) => this.options.current.onFocus?.(...args))
editor.on('selectionUpdate', (...args) => this.options.current.onSelectionUpdate?.(...args))
editor.on('transaction', (...args) => this.options.current.onTransaction?.(...args))
editor.on('update', (...args) => this.options.current.onUpdate?.(...args))
editor.on('contentError', (...args) => this.options.current.onContentError?.(...args))
// no need to keep track of the event listeners, they will be removed when the editor is destroyed
return editor
}
/**
* Get the current editor instance.
*/
getEditor(): Editor | null {
return this.editor
}
/**
* Always disable the editor on the server-side.
*/
getServerSnapshot(): null {
return null
}
/**
* Subscribe to the editor instance's changes.
*/
subscribe(onStoreChange: () => void) {
this.subscriptions.add(onStoreChange)
return () => {
this.subscriptions.delete(onStoreChange)
}
}
/**
* On each render, we will create, update, or destroy the editor instance.
* @param deps The dependencies to watch for changes
* @returns A cleanup function
*/
onRender(deps: DependencyList) {
// The returned callback will run on each render
return () => {
this.isComponentMounted = true
// Cleanup any scheduled destructions, since we are currently rendering
clearTimeout(this.scheduledDestructionTimeout)
if (this.editor && !this.editor.isDestroyed && deps.length === 0) {
// if the editor does exist & deps are empty, we don't need to re-initialize the editor
// we can fast-path to update the editor options on the existing instance
this.editor.setOptions(this.options.current)
} else {
// When the editor:
// - does not yet exist
// - is destroyed
// - the deps array changes
// We need to destroy the editor instance and re-initialize it
this.refreshEditorInstance(deps)
}
return () => {
this.isComponentMounted = false
this.scheduleDestroy()
}
}
}
/**
* Recreate the editor instance if the dependencies have changed.
*/
private refreshEditorInstance(deps: DependencyList) {
if (this.editor && !this.editor.isDestroyed) {
// Editor instance already exists
if (this.previousDeps === null) {
// If lastDeps has not yet been initialized, reuse the current editor instance
this.previousDeps = deps
return
}
const depsAreEqual = this.previousDeps.length === deps.length
&& this.previousDeps.every((dep, index) => dep === deps[index])
if (depsAreEqual) {
// deps exist and are equal, no need to recreate
return
}
}
if (this.editor && !this.editor.isDestroyed) {
// Destroy the editor instance if it exists
this.editor.destroy()
}
this.setEditor(this.createEditor())
// Update the lastDeps to the current deps
this.previousDeps = deps
}
/**
* Schedule the destruction of the editor instance.
* This will only destroy the editor if it was not mounted on the next tick.
* This is to avoid destroying the editor instance when it's actually still mounted.
*/
private scheduleDestroy() {
const currentInstanceId = this.instanceId
const currentEditor = this.editor
// Wait a tick to see if the component is still mounted
this.scheduledDestructionTimeout = setTimeout(() => {
if (this.isComponentMounted && this.instanceId === currentInstanceId) {
// If still mounted on the next tick, with the same instanceId, do not destroy the editor
if (currentEditor) {
// just re-apply options as they might have changed
currentEditor.setOptions(this.options.current)
}
return
}
if (currentEditor && !currentEditor.isDestroyed) {
currentEditor.destroy()
if (this.instanceId === currentInstanceId) {
this.setEditor(null)
}
}
}, 0)
}
}
/**
@ -68,99 +292,29 @@ export function useEditor(
* @returns The editor instance
* @example const editor = useEditor({ extensions: [...] })
*/
export function useEditor(
options?: UseEditorOptions,
deps?: DependencyList
): Editor | null;
export function useEditor(options?: UseEditorOptions, deps?: DependencyList): Editor | null;
export function useEditor(
options: UseEditorOptions = {},
deps: DependencyList = [],
): Editor | null {
const mostRecentOptions = useRef(options)
const [editor, setEditor] = useState(() => {
if (options.immediatelyRender === undefined) {
if (isSSR || isNext) {
// TODO in the next major release, we should throw an error here
if (isDev) {
/**
* Throw an error in development, to make sure the developer is aware that tiptap cannot be SSR'd
* and that they need to set `immediatelyRender` to `false` to avoid hydration mismatches.
*/
console.warn(
'Tiptap Error: SSR has been detected, please set `immediatelyRender` explicitly to `false` to avoid hydration mismatches.',
)
}
// Best faith effort in production, run the code in the legacy mode to avoid hydration mismatches and errors in production
return null
}
mostRecentOptions.current = options
// Default to immediately rendering when client-side rendering
return createEditor(mostRecentOptions)
}
const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions))
if (options.immediatelyRender && isSSR && isDev) {
// Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches.
throw new Error(
'Tiptap Error: SSR has been detected, and `immediatelyRender` has been set to `true` this is an unsupported configuration that may result in errors, explicitly set `immediatelyRender` to `false` to avoid hydration mismatches.',
)
}
if (options.immediatelyRender) {
return createEditor(mostRecentOptions)
}
return null
})
const mostRecentEditor = useRef<Editor | null>(editor)
mostRecentEditor.current = editor
const editor = useSyncExternalStore(
instanceManager.subscribe,
instanceManager.getEditor,
instanceManager.getServerSnapshot,
)
useDebugValue(editor)
// This effect will handle creating/updating the editor instance
useEffect(() => {
const destroyUnusedEditor = (editorInstance: Editor | null) => {
if (editorInstance) {
// We need to destroy the editor asynchronously to avoid memory leaks
// because the editor instance is still being used in the component.
setTimeout(() => {
// re-use the editor instance if it hasn't been replaced yet
// otherwise, asynchronously destroy the old editor instance
if (editorInstance !== mostRecentEditor.current && !editorInstance.isDestroyed) {
editorInstance.destroy()
}
})
}
}
let editorInstance = mostRecentEditor.current
if (!editorInstance) {
editorInstance = createEditor(mostRecentOptions)
setEditor(editorInstance)
return () => destroyUnusedEditor(editorInstance)
}
if (!Array.isArray(deps) || deps.length === 0) {
// if the editor does exist & deps are empty, we don't need to re-initialize the editor
// we can fast-path to update the editor options on the existing instance
editorInstance.setOptions(options)
return () => destroyUnusedEditor(editorInstance)
}
// We need to destroy the editor instance and re-initialize it
// when the deps array changes
editorInstance.destroy()
// the deps array is used to re-initialize the editor instance
editorInstance = createEditor(mostRecentOptions)
setEditor(editorInstance)
return () => destroyUnusedEditor(editorInstance)
}, deps)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(instanceManager.onRender(deps))
// The default behavior is to re-render on each transaction
// This is legacy behavior that will be removed in future versions

View File

@ -30,68 +30,83 @@ export type UseEditorStateOptions<
* To synchronize the editor instance with the component state,
* we need to create a separate instance that is not affected by the component re-renders.
*/
function makeEditorStateInstance<TEditor extends Editor | null = Editor | null>(initialEditor: TEditor) {
let transactionNumber = 0
let lastTransactionNumber = 0
let lastSnapshot: EditorStateSnapshot<TEditor> = { editor: initialEditor, transactionNumber: 0 }
let editor = initialEditor
const subscribers = new Set<() => void>()
class EditorStateManager<TEditor extends Editor | null = Editor | null> {
private transactionNumber = 0
const editorInstance = {
/**
* Get the current editor instance.
*/
getSnapshot(): EditorStateSnapshot<TEditor> {
if (transactionNumber === lastTransactionNumber) {
return lastSnapshot
}
lastTransactionNumber = transactionNumber
lastSnapshot = { editor, transactionNumber }
return lastSnapshot
},
/**
* Always disable the editor on the server-side.
*/
getServerSnapshot(): EditorStateSnapshot<null> {
return { editor: null, transactionNumber: 0 }
},
/**
* Subscribe to the editor instance's changes.
*/
subscribe(callback: () => void) {
subscribers.add(callback)
return () => {
subscribers.delete(callback)
}
},
/**
* Watch the editor instance for changes.
*/
watch(nextEditor: Editor | null) {
editor = nextEditor as TEditor
private lastTransactionNumber = 0
if (editor) {
/**
* This will force a re-render when the editor state changes.
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`.
* This could be more efficient, but it's a good trade-off for now.
*/
const fn = () => {
transactionNumber += 1
subscribers.forEach(callback => callback())
}
private lastSnapshot: EditorStateSnapshot<TEditor>
const currentEditor = editor
private editor: TEditor
currentEditor.on('transaction', fn)
return () => {
currentEditor.off('transaction', fn)
}
}
},
private subscribers = new Set<() => void>()
constructor(initialEditor: TEditor) {
this.editor = initialEditor
this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 }
this.getSnapshot = this.getSnapshot.bind(this)
this.getServerSnapshot = this.getServerSnapshot.bind(this)
this.watch = this.watch.bind(this)
this.subscribe = this.subscribe.bind(this)
}
return editorInstance
/**
* Get the current editor instance.
*/
getSnapshot(): EditorStateSnapshot<TEditor> {
if (this.transactionNumber === this.lastTransactionNumber) {
return this.lastSnapshot
}
this.lastTransactionNumber = this.transactionNumber
this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber }
return this.lastSnapshot
}
/**
* Always disable the editor on the server-side.
*/
getServerSnapshot(): EditorStateSnapshot<null> {
return { editor: null, transactionNumber: 0 }
}
/**
* Subscribe to the editor instance's changes.
*/
subscribe(callback: () => void): () => void {
this.subscribers.add(callback)
return () => {
this.subscribers.delete(callback)
}
}
/**
* Watch the editor instance for changes.
*/
watch(nextEditor: Editor | null): undefined | (() => void) {
this.editor = nextEditor as TEditor
if (this.editor) {
/**
* This will force a re-render when the editor state changes.
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`.
* This could be more efficient, but it's a good trade-off for now.
*/
const fn = () => {
this.transactionNumber += 1
this.subscribers.forEach(callback => callback())
}
const currentEditor = this.editor
currentEditor.on('transaction', fn)
return () => {
currentEditor.off('transaction', fn)
}
}
return undefined
}
}
export function useEditorState<TSelectorResult>(
@ -104,7 +119,7 @@ export function useEditorState<TSelectorResult>(
export function useEditorState<TSelectorResult>(
options: UseEditorStateOptions<TSelectorResult, Editor> | UseEditorStateOptions<TSelectorResult, Editor | null>,
): TSelectorResult | null {
const [editorInstance] = useState(() => makeEditorStateInstance(options.editor))
const [editorInstance] = useState(() => new EditorStateManager(options.editor))
// Using the `useSyncExternalStore` hook to sync the editor instance with the component state
const selectedState = useSyncExternalStoreWithSelector(
@ -117,7 +132,7 @@ export function useEditorState<TSelectorResult>(
useEffect(() => {
return editorInstance.watch(options.editor)
}, [options.editor])
}, [options.editor, editorInstance])
useDebugValue(selectedState)

View File

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { Editor as CoreEditor, EditorOptions } from '@tiptap/core'
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
import {