fix: default to rendering the editor immediately, while staying backward compatible (#5161)

* fix: default to rendering the editor immediately, while staying backward compatible

* feat: add `useEditorWithState` hook for extracting state and editor instance simultaneously

* feat(react): add `useEditorState` hook for subscribing to selected editor state

* docs: add an example to show the concept

* chore: add changeset
This commit is contained in:
Nick Perez 2024-07-10 11:41:43 +02:00 committed by GitHub
parent 5c2f67f44d
commit df5609cdff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 903 additions and 346 deletions

View File

@ -0,0 +1,67 @@
---
"@tiptap/react": patch
---
We've heard a number of complaints around the performance of our React integration, and we finally have a solution that we believe will satisfy everyone. We've made a number of optimizations to how the editor is rendered, as well give you more control over the rendering process.
Here is a summary of the changes and how you can take advantage of them:
- SSR rendering was holding back our ability to have an editor instance on first render of `useEditor`. We've now made the default behavior to render the editor immediately on the client. This behavior can be controlled with the new `immediatelyRender` option which when set to `false` will defer rendering until the second render (via a useEffect), this should only be used when server-side rendering.
- The default behavior of the useEditor hook is to re-render the editor on every editor transaction. Now with the `shouldRerenderOnTransaction` option, you can disable this behavior to optimize performance. Instead, to access the new editor state, you can use the `useEditorState` hook.
- `useEditorState` this new hook allows you to select from the editor instance any state you need to render your UI. This is useful when you want to optimize performance by only re-rendering the parts of your UI that need to be updated.
Here is a usage example:
```jsx
const editor = useEditor({
/**
* This option gives us the control to enable the default behavior of rendering the editor immediately.
*/
immediatelyRender: true,
/**
* This option gives us the control to disable the default behavior of re-rendering the editor on every transaction.
*/
shouldRerenderOnTransaction: false,
extensions: [StarterKit],
content: `
<p>
A highly optimized editor that only re-renders when its necessary.
</p>
`,
})
/**
* This hook allows us to select the editor state we want to use in our component.
*/
const currentEditorState = useEditorState({
/**
* The editor instance we want to use.
*/
editor,
/**
* This selector allows us to select the data we want to use in our component.
* It is evaluated on every editor transaction and compared to it's previously returned value.
* You can return any data shape you want.
*/
selector: ctx => ({
isBold: ctx.editor.isActive('bold'),
isItalic: ctx.editor.isActive('italic'),
isStrike: ctx.editor.isActive('strike'),
}),
/**
* This function allows us to customize the equality check for the selector.
* By default it is a `===` check.
*/
equalityFn: (prev, next) => {
// A deep-equal function would probably be more maintainable here, but, we use a shallow one to show that it can be customized.
if (!next) {
return false
}
return (
prev.isBold === next.isBold
&& prev.isItalic === next.isItalic
&& prev.isStrike === next.isStrike
)
},
})
```

View File

@ -0,0 +1,130 @@
import './styles.scss'
import {
BubbleMenu, EditorContent, useEditor, useEditorState,
} from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
function EditorInstance({ shouldOptimizeRendering }) {
const countRenderRef = React.useRef(0)
countRenderRef.current += 1
const editor = useEditor({
/**
* This option gives us the control to enable the default behavior of rendering the editor immediately.
*/
immediatelyRender: true,
/**
* This option gives us the control to disable the default behavior of re-rendering the editor on every transaction.
*/
shouldRerenderOnTransaction: !shouldOptimizeRendering,
extensions: [StarterKit],
content: `
<p>
A highly optimized editor that only re-renders when its necessary.
</p>
`,
})
/**
* This hook allows us to select the editor state we want to use in our component.
*/
const currentEditorState = useEditorState({
/**
* The editor instance we want to use.
*/
editor,
/**
* This selector allows us to select the data we want to use in our component.
* It is evaluated on every editor transaction and compared to it's previously returned value.
*/
selector: ctx => ({
isBold: ctx.editor.isActive('bold'),
isItalic: ctx.editor.isActive('italic'),
isStrike: ctx.editor.isActive('strike'),
}),
/**
* This function allows us to customize the equality check for the selector.
* By default it is a `===` check.
*/
equalityFn: (prev, next) => {
// A deep-equal function would probably be more maintainable here, but, we use a shallow one to show that it can be customized.
if (!next) {
return false
}
return (
prev.isBold === next.isBold
&& prev.isItalic === next.isItalic
&& prev.isStrike === next.isStrike
)
},
})
return (
<>
<div className="control-group">
<div>Number of renders: <span id="render-count">{countRenderRef.current}</span></div>
</div>
{currentEditorState && (
<BubbleMenu className="bubble-menu" tippyOptions={{ duration: 100 }} editor={editor}>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={currentEditorState.isBold ? 'is-active' : ''}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={currentEditorState.isItalic ? 'is-active' : ''}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={currentEditorState.isStrike ? 'is-active' : ''}
>
Strike
</button>
</BubbleMenu>
)}
<EditorContent editor={editor} />
</>
)
}
export default () => {
const [shouldOptimizeRendering, setShouldOptimizeRendering] = React.useState(true)
return (
<>
<div className="control-group">
<div className="switch-group">
<label>
<input
type="radio"
name="option-switch"
onChange={() => {
setShouldOptimizeRendering(true)
}}
checked={shouldOptimizeRendering === true}
/>
Optimize rendering
</label>
<label>
<input
type="radio"
name="option-switch"
onChange={() => {
setShouldOptimizeRendering(false)
}}
checked={shouldOptimizeRendering === false}
/>
Render every transaction (default behavior)
</label>
</div>
</div>
<EditorInstance shouldOptimizeRendering={shouldOptimizeRendering} />
</>
)
}

View File

@ -0,0 +1,12 @@
context('/src/Examples/Performance/React/', () => {
beforeEach(() => {
cy.visit('/src/Examples/Performance/React/')
})
it('should have a working tiptap instance', () => {
cy.get('.tiptap').then(([{ editor }]) => {
// eslint-disable-next-line
expect(editor).to.not.be.null
})
})
})

View File

@ -0,0 +1,91 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* List styles */
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
}
h1,
h2 {
margin-top: 3.5rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
code {
background-color: var(--purple-light);
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
pre {
background: var(--black);
border-radius: 0.5rem;
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
}
hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}
}

593
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,9 @@
],
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.5.0-pre.13",
"@tiptap/extension-floating-menu": "^2.5.0-pre.13"
"@tiptap/extension-floating-menu": "^2.5.0-pre.13",
"use-sync-external-store": "^1.2.2",
"@types/use-sync-external-store": "^0.0.6"
},
"devDependencies": {
"@tiptap/core": "^2.5.0-pre.13",

View File

@ -1,9 +1,8 @@
import { EditorOptions } from '@tiptap/core'
import React, { createContext, ReactNode, useContext } from 'react'
import { Editor } from './Editor.js'
import { EditorContent } from './EditorContent.js'
import { useEditor } from './useEditor.js'
import { useEditor, UseEditorOptions } from './useEditor.js'
export type EditorContextValue = {
editor: Editor | null;
@ -15,17 +14,25 @@ export const EditorContext = createContext<EditorContextValue>({
export const EditorConsumer = EditorContext.Consumer
/**
* A hook to get the current editor instance.
*/
export const useCurrentEditor = () => useContext(EditorContext)
export type EditorProviderProps = {
children?: ReactNode;
slotBefore?: ReactNode;
slotAfter?: ReactNode;
} & Partial<EditorOptions>
} & UseEditorOptions
export const EditorProvider = ({
/**
* This is the provider component for the editor.
* It allows the editor to be accessible across the entire component tree
* with `useCurrentEditor`.
*/
export function EditorProvider({
children, slotAfter, slotBefore, ...editorOptions
}: EditorProviderProps) => {
}: EditorProviderProps) {
const editor = useEditor(editorOptions)
if (!editor) {

View File

@ -8,5 +8,6 @@ export * from './NodeViewWrapper.js'
export * from './ReactNodeViewRenderer.js'
export * from './ReactRenderer.js'
export * from './useEditor.js'
export * from './useEditorState.js'
export * from './useReactNodeView.js'
export * from '@tiptap/core'

View File

@ -1,12 +1,33 @@
import { EditorOptions } from '@tiptap/core'
import {
DependencyList,
useEffect,
useRef,
useState,
DependencyList, useDebugValue, useEffect, useRef, useState,
} from 'react'
import { Editor } from './Editor.js'
import { useEditorState } from './useEditorState.js'
const isDev = process.env.NODE_ENV !== 'production'
const isSSR = typeof window === 'undefined'
const isNext = isSSR || Boolean(typeof window !== 'undefined' && (window as any).next)
/**
* The options for the `useEditor` hook.
*/
export type UseEditorOptions = Partial<EditorOptions> & {
/**
* Whether to render the editor on the first render.
* If client-side rendering, set this to `true`.
* If server-side rendering, set this to `false`.
* @default true
*/
immediatelyRender?: boolean;
/**
* Whether to re-render the editor on each transaction.
* This is legacy behavior that will be removed in future versions.
* @default true
*/
shouldRerenderOnTransaction?: boolean;
};
/**
* This hook allows you to create an editor instance.
@ -15,9 +36,79 @@ import { Editor } from './Editor.js'
* @returns The editor instance
* @example const editor = useEditor({ extensions: [...] })
*/
export const useEditor = (options: Partial<EditorOptions> = {}, deps: DependencyList = []) => {
const editorRef = useRef<Editor | null>(null)
const [, forceUpdate] = useState({})
export function useEditor(
options: UseEditorOptions & { immediatelyRender: true },
deps?: DependencyList
): Editor;
/**
* This hook allows you to create an editor instance.
* @param options The editor options
* @param deps The dependencies to watch for changes
* @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 {
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
}
// Default to immediately rendering when client-side rendering
return new Editor(options)
}
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 new Editor(options)
}
return null
})
useDebugValue(editor)
// This effect will handle creating/updating the editor instance
useEffect(() => {
let editorInstance: Editor | null = editor
if (!editorInstance) {
editorInstance = new Editor(options)
// instantiate the editor if it doesn't exist
// for ssr, this is the first time the editor is created
setEditor(editorInstance)
} else {
// if the editor does exist, update the editor options accordingly
editorInstance.setOptions(options)
}
}, deps)
const {
onBeforeCreate,
@ -44,96 +135,118 @@ export const useEditor = (options: Partial<EditorOptions> = {}, deps: Dependency
// This effect will handle updating the editor instance
// when the event handlers change.
useEffect(() => {
if (!editorRef.current) {
if (!editor) {
return
}
if (onBeforeCreate) {
editorRef.current.off('beforeCreate', onBeforeCreateRef.current)
editorRef.current.on('beforeCreate', onBeforeCreate)
editor.off('beforeCreate', onBeforeCreateRef.current)
editor.on('beforeCreate', onBeforeCreate)
onBeforeCreateRef.current = onBeforeCreate
}
if (onBlur) {
editorRef.current.off('blur', onBlurRef.current)
editorRef.current.on('blur', onBlur)
editor.off('blur', onBlurRef.current)
editor.on('blur', onBlur)
onBlurRef.current = onBlur
}
if (onCreate) {
editorRef.current.off('create', onCreateRef.current)
editorRef.current.on('create', onCreate)
editor.off('create', onCreateRef.current)
editor.on('create', onCreate)
onCreateRef.current = onCreate
}
if (onDestroy) {
editorRef.current.off('destroy', onDestroyRef.current)
editorRef.current.on('destroy', onDestroy)
editor.off('destroy', onDestroyRef.current)
editor.on('destroy', onDestroy)
onDestroyRef.current = onDestroy
}
if (onFocus) {
editorRef.current.off('focus', onFocusRef.current)
editorRef.current.on('focus', onFocus)
editor.off('focus', onFocusRef.current)
editor.on('focus', onFocus)
onFocusRef.current = onFocus
}
if (onSelectionUpdate) {
editorRef.current.off('selectionUpdate', onSelectionUpdateRef.current)
editorRef.current.on('selectionUpdate', onSelectionUpdate)
editor.off('selectionUpdate', onSelectionUpdateRef.current)
editor.on('selectionUpdate', onSelectionUpdate)
onSelectionUpdateRef.current = onSelectionUpdate
}
if (onTransaction) {
editorRef.current.off('transaction', onTransactionRef.current)
editorRef.current.on('transaction', onTransaction)
editor.off('transaction', onTransactionRef.current)
editor.on('transaction', onTransaction)
onTransactionRef.current = onTransaction
}
if (onUpdate) {
editorRef.current.off('update', onUpdateRef.current)
editorRef.current.on('update', onUpdate)
editor.off('update', onUpdateRef.current)
editor.on('update', onUpdate)
onUpdateRef.current = onUpdate
}
if (onContentError) {
editorRef.current.off('contentError', onContentErrorRef.current)
editorRef.current.on('contentError', onContentError)
editor.off('contentError', onContentErrorRef.current)
editor.on('contentError', onContentError)
onContentErrorRef.current = onContentError
}
}, [onBeforeCreate, onBlur, onCreate, onDestroy, onFocus, onSelectionUpdate, onTransaction, onUpdate, editorRef.current])
}, [
onBeforeCreate,
onBlur,
onCreate,
onDestroy,
onFocus,
onSelectionUpdate,
onTransaction,
onUpdate,
onContentError,
editor,
])
/**
* Destroy the editor instance when the component completely unmounts
* As opposed to the cleanup function in the effect above, this will
* only be called when the component is removed from the DOM, since it has no deps.
* */
useEffect(() => {
let isMounted = true
const editor = new Editor(options)
editorRef.current = editor
editorRef.current.on('transaction', () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (isMounted) {
forceUpdate({})
}
})
})
})
return () => {
isMounted = false
editor.destroy()
}
}, deps)
if (editor) {
// We need to destroy the editor asynchronously to avoid memory leaks
// because the editor instance is still being used in the component.
return editorRef.current
setTimeout(() => (editor.isDestroyed ? null : editor.destroy()))
}
}
}, [])
// The default behavior is to re-render on each transaction
// This is legacy behavior that will be removed in future versions
useEditorState({
editor,
selector: ({ transactionNumber }) => {
if (options.shouldRerenderOnTransaction === false) {
// This will prevent the editor from re-rendering on each transaction
return null
}
// This will avoid re-rendering on the first transaction when `immediatelyRender` is set to `true`
if (options.immediatelyRender && transactionNumber === 0) {
return 0
}
return transactionNumber + 1
},
})
return editor
}

View File

@ -0,0 +1,125 @@
import { useDebugValue, useEffect, useState } from 'react'
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
import type { Editor } from './Editor.js'
export type EditorStateSnapshot<TEditor extends Editor | null = Editor | null> = {
editor: TEditor;
transactionNumber: number;
};
export type UseEditorStateOptions<
TSelectorResult,
TEditor extends Editor | null = Editor | null,
> = {
/**
* The editor instance.
*/
editor: TEditor;
/**
* A selector function to determine the value to compare for re-rendering.
*/
selector: (context: EditorStateSnapshot<TEditor>) => TSelectorResult;
/**
* A custom equality function to determine if the editor should re-render.
* @default `(a, b) => a === b`
*/
equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => boolean;
};
/**
* 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>()
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
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())
}
const currentEditor = editor
currentEditor.on('transaction', fn)
return () => {
currentEditor.off('transaction', fn)
}
}
},
}
return editorInstance
}
export function useEditorState<TSelectorResult>(
options: UseEditorStateOptions<TSelectorResult, Editor | null>
): TSelectorResult | null;
export function useEditorState<TSelectorResult>(
options: UseEditorStateOptions<TSelectorResult, Editor>
): TSelectorResult;
export function useEditorState<TSelectorResult>(
options: UseEditorStateOptions<TSelectorResult, Editor> | UseEditorStateOptions<TSelectorResult, Editor | null>,
): TSelectorResult | null {
const [editorInstance] = useState(() => makeEditorStateInstance(options.editor))
// Using the `useSyncExternalStore` hook to sync the editor instance with the component state
const selectedState = useSyncExternalStoreWithSelector(
editorInstance.subscribe,
editorInstance.getSnapshot,
editorInstance.getServerSnapshot,
options.selector as UseEditorStateOptions<TSelectorResult, Editor | null>['selector'],
options.equalityFn,
)
useEffect(() => {
return editorInstance.watch(options.editor)
}, [options.editor])
useDebugValue(selectedState)
return selectedState
}