mirror of
https://github.com/ueberdosis/tiptap.git
synced 2024-11-23 19:19:03 +08:00
fix(react): optimize useEditor
and useEditorState
to reduce number of instances created while being performant #5432 (#5445)
This commit is contained in:
parent
84ebd511d2
commit
7c8889a2a6
12
.changeset/smooth-rice-obey.md
Normal file
12
.changeset/smooth-rice-obey.md
Normal 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`.
|
@ -8,6 +8,15 @@ module.exports = {
|
|||||||
node: true,
|
node: true,
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'./**/*.ts',
|
||||||
|
'./**/*.tsx',
|
||||||
|
'./**/*.js',
|
||||||
|
'./**/*.jsx',
|
||||||
|
],
|
||||||
|
extends: ['plugin:react-hooks/recommended'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: [
|
files: [
|
||||||
'./**/*.ts',
|
'./**/*.ts',
|
||||||
|
@ -8,10 +8,6 @@ import StarterKit from '@tiptap/starter-kit'
|
|||||||
import React, { useCallback } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
|
|
||||||
const MenuBar = ({ editor }) => {
|
const MenuBar = ({ editor }) => {
|
||||||
if (!editor) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCutToStart = useCallback(() => {
|
const onCutToStart = useCallback(() => {
|
||||||
editor.chain().cut({ from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos }, 1).run()
|
editor.chain().cut({ from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos }, 1).run()
|
||||||
}, [editor])
|
}, [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.chain().cut({ from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos }, editor.state.doc.nodeSize - 2).run()
|
||||||
}, [editor])
|
}, [editor])
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="control-group">
|
<div className="control-group">
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
|
@ -62,7 +62,7 @@ function EditorInstance({ shouldOptimizeRendering }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<div className="control-group">
|
<div className="control-group">
|
||||||
<div>Number of renders: <span id="render-count">{countRenderRef.current}</span></div>
|
<div>Number of renders: <span id="render-count">{countRenderRef.current}</span></div>
|
||||||
</div>
|
</div>
|
||||||
@ -89,12 +89,13 @@ function EditorInstance({ shouldOptimizeRendering }) {
|
|||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
)}
|
)}
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditorControls = () => {
|
const EditorControls = () => {
|
||||||
const [shouldOptimizeRendering, setShouldOptimizeRendering] = React.useState(true)
|
const [shouldOptimizeRendering, setShouldOptimizeRendering] = React.useState(true)
|
||||||
|
const [rendered, setRendered] = React.useState(true)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -123,8 +124,9 @@ const EditorControls = () => {
|
|||||||
Render every transaction (default behavior)
|
Render every transaction (default behavior)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={() => setRendered(a => !a)}>Toggle rendered</button>
|
||||||
</div>
|
</div>
|
||||||
<EditorInstance shouldOptimizeRendering={shouldOptimizeRendering} />
|
{rendered && <EditorInstance shouldOptimizeRendering={shouldOptimizeRendering} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import React, { useEffect, useState } from 'react'
|
|||||||
export default () => {
|
export default () => {
|
||||||
const [editable, setEditable] = useState(false)
|
const [editable, setEditable] = useState(false)
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
shouldRerenderOnTransaction: false,
|
||||||
editable,
|
editable,
|
||||||
content: `
|
content: `
|
||||||
<p>
|
<p>
|
||||||
|
479
package-lock.json
generated
479
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -58,6 +58,7 @@
|
|||||||
"eslint-plugin-cypress": "^2.15.2",
|
"eslint-plugin-cypress": "^2.15.2",
|
||||||
"eslint-plugin-html": "^6.2.0",
|
"eslint-plugin-html": "^6.2.0",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
|
"eslint-plugin-react-hooks": "4.6.2",
|
||||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||||
"eslint-plugin-vue": "^9.27.0",
|
"eslint-plugin-vue": "^9.27.0",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
import { EditorOptions } from '@tiptap/core'
|
import { EditorOptions } from '@tiptap/core'
|
||||||
import {
|
import {
|
||||||
DependencyList, MutableRefObject,
|
DependencyList,
|
||||||
useDebugValue, useEffect, useRef, useState,
|
MutableRefObject,
|
||||||
|
useDebugValue,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
import { useSyncExternalStore } from 'use-sync-external-store/shim'
|
||||||
|
|
||||||
import { Editor } from './Editor.js'
|
import { Editor } from './Editor.js'
|
||||||
import { useEditorState } from './useEditorState.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 {
|
class EditorInstanceManager {
|
||||||
const editor = new Editor(options.current)
|
/**
|
||||||
|
* 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))
|
* The most recent options to apply to the editor.
|
||||||
editor.on('create', (...args) => options.current.onCreate?.(...args))
|
*/
|
||||||
editor.on('destroy', (...args) => options.current.onDestroy?.(...args))
|
private options: MutableRefObject<UseEditorOptions>
|
||||||
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))
|
|
||||||
|
|
||||||
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
|
* @returns The editor instance
|
||||||
* @example const editor = useEditor({ extensions: [...] })
|
* @example const editor = useEditor({ extensions: [...] })
|
||||||
*/
|
*/
|
||||||
export function useEditor(
|
export function useEditor(options?: UseEditorOptions, deps?: DependencyList): Editor | null;
|
||||||
options?: UseEditorOptions,
|
|
||||||
deps?: DependencyList
|
|
||||||
): Editor | null;
|
|
||||||
|
|
||||||
export function useEditor(
|
export function useEditor(
|
||||||
options: UseEditorOptions = {},
|
options: UseEditorOptions = {},
|
||||||
deps: DependencyList = [],
|
deps: DependencyList = [],
|
||||||
): Editor | null {
|
): Editor | null {
|
||||||
const mostRecentOptions = useRef(options)
|
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
|
mostRecentOptions.current = options
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to immediately rendering when client-side rendering
|
const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions))
|
||||||
return createEditor(mostRecentOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.immediatelyRender && isSSR && isDev) {
|
const editor = useSyncExternalStore(
|
||||||
// Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches.
|
instanceManager.subscribe,
|
||||||
throw new Error(
|
instanceManager.getEditor,
|
||||||
'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.',
|
instanceManager.getServerSnapshot,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (options.immediatelyRender) {
|
|
||||||
return createEditor(mostRecentOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
const mostRecentEditor = useRef<Editor | null>(editor)
|
|
||||||
|
|
||||||
mostRecentEditor.current = editor
|
|
||||||
|
|
||||||
useDebugValue(editor)
|
useDebugValue(editor)
|
||||||
|
|
||||||
// This effect will handle creating/updating the editor instance
|
// This effect will handle creating/updating the editor instance
|
||||||
useEffect(() => {
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const destroyUnusedEditor = (editorInstance: Editor | null) => {
|
useEffect(instanceManager.onRender(deps))
|
||||||
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)
|
|
||||||
|
|
||||||
// The default behavior is to re-render on each transaction
|
// The default behavior is to re-render on each transaction
|
||||||
// This is legacy behavior that will be removed in future versions
|
// This is legacy behavior that will be removed in future versions
|
||||||
|
@ -30,68 +30,83 @@ export type UseEditorStateOptions<
|
|||||||
* To synchronize the editor instance with the component state,
|
* 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.
|
* 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) {
|
class EditorStateManager<TEditor extends Editor | null = Editor | null> {
|
||||||
let transactionNumber = 0
|
private transactionNumber = 0
|
||||||
let lastTransactionNumber = 0
|
|
||||||
let lastSnapshot: EditorStateSnapshot<TEditor> = { editor: initialEditor, transactionNumber: 0 }
|
|
||||||
let editor = initialEditor
|
|
||||||
const subscribers = new Set<() => void>()
|
|
||||||
|
|
||||||
const editorInstance = {
|
private lastTransactionNumber = 0
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
|
|
||||||
if (editor) {
|
private lastSnapshot: EditorStateSnapshot<TEditor>
|
||||||
/**
|
|
||||||
* 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())
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentEditor = editor
|
private editor: TEditor
|
||||||
|
|
||||||
currentEditor.on('transaction', fn)
|
private subscribers = new Set<() => void>()
|
||||||
return () => {
|
|
||||||
currentEditor.off('transaction', fn)
|
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>(
|
export function useEditorState<TSelectorResult>(
|
||||||
@ -104,7 +119,7 @@ export function useEditorState<TSelectorResult>(
|
|||||||
export function useEditorState<TSelectorResult>(
|
export function useEditorState<TSelectorResult>(
|
||||||
options: UseEditorStateOptions<TSelectorResult, Editor> | UseEditorStateOptions<TSelectorResult, Editor | null>,
|
options: UseEditorStateOptions<TSelectorResult, Editor> | UseEditorStateOptions<TSelectorResult, Editor | null>,
|
||||||
): TSelectorResult | 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
|
// Using the `useSyncExternalStore` hook to sync the editor instance with the component state
|
||||||
const selectedState = useSyncExternalStoreWithSelector(
|
const selectedState = useSyncExternalStoreWithSelector(
|
||||||
@ -117,7 +132,7 @@ export function useEditorState<TSelectorResult>(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return editorInstance.watch(options.editor)
|
return editorInstance.watch(options.editor)
|
||||||
}, [options.editor])
|
}, [options.editor, editorInstance])
|
||||||
|
|
||||||
useDebugValue(selectedState)
|
useDebugValue(selectedState)
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-hooks/rules-of-hooks */
|
||||||
import { Editor as CoreEditor, EditorOptions } from '@tiptap/core'
|
import { Editor as CoreEditor, EditorOptions } from '@tiptap/core'
|
||||||
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
|
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
import {
|
import {
|
||||||
|
Loading…
Reference in New Issue
Block a user