From f157449256461542c9b06f5ca370d81e1b47257f Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Sun, 7 Jul 2024 14:26:14 +0200 Subject: [PATCH] feat(react): add `useEditorState` hook for subscribing to selected editor state --- .../Examples/CustomParagraph/React/index.jsx | 4 + packages/react/src/Context.tsx | 8 +- packages/react/src/index.ts | 1 + packages/react/src/useEditor.ts | 237 ++++-------------- packages/react/src/useEditorState.ts | 125 +++++++++ 5 files changed, 183 insertions(+), 192 deletions(-) create mode 100644 packages/react/src/useEditorState.ts diff --git a/demos/src/Examples/CustomParagraph/React/index.jsx b/demos/src/Examples/CustomParagraph/React/index.jsx index 67f7df514e..beb8049f5b 100644 --- a/demos/src/Examples/CustomParagraph/React/index.jsx +++ b/demos/src/Examples/CustomParagraph/React/index.jsx @@ -8,6 +8,8 @@ import { Paragraph } from './Paragraph.jsx' export default () => { const editor = useEditor({ + immediatelyRender: true, + shouldRerenderOnTransaction: false, extensions: [ StarterKit.configure({ paragraph: false, @@ -21,6 +23,8 @@ export default () => { `, }) + console.count('render') + return ( ) diff --git a/packages/react/src/Context.tsx b/packages/react/src/Context.tsx index f8ce63869d..d5b24f5624 100644 --- a/packages/react/src/Context.tsx +++ b/packages/react/src/Context.tsx @@ -19,20 +19,20 @@ export const EditorConsumer = EditorContext.Consumer */ export const useCurrentEditor = () => useContext(EditorContext) -export type EditorProviderProps = { +export type EditorProviderProps = { children?: ReactNode; slotBefore?: ReactNode; slotAfter?: ReactNode; -} & UseEditorOptions +} & UseEditorOptions /** * 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({ +export function EditorProvider({ children, slotAfter, slotBefore, ...editorOptions -}: EditorProviderProps) { +}: EditorProviderProps) { const editor = useEditor(editorOptions) if (!editor) { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 69128042f9..12f14edffc 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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' diff --git a/packages/react/src/useEditor.ts b/packages/react/src/useEditor.ts index 08321c2cc9..c654153cbb 100644 --- a/packages/react/src/useEditor.ts +++ b/packages/react/src/useEditor.ts @@ -2,9 +2,9 @@ import { EditorOptions } from '@tiptap/core' import { DependencyList, useDebugValue, useEffect, useRef, useState, } from 'react' -import { useSyncExternalStore } from 'use-sync-external-store/shim' import { Editor } from './Editor.js' +import { useEditorState } from './useEditorState.js' const isDev = process.env.NODE_ENV !== 'production' const isSSR = typeof window === 'undefined' @@ -13,7 +13,7 @@ const isNext = isSSR || Boolean(typeof window !== 'undefined' && (window as any) /** * The options for the `useEditor` hook. */ -export type UseEditorOptions = Partial & { +export type UseEditorOptions = Partial & { /** * Whether to render the editor on the first render. * If client-side rendering, set this to `true`. @@ -22,128 +22,13 @@ export type UseEditorOptions = Partial & { */ immediatelyRender?: boolean; /** - * A selector function to determine the value to compare for re-rendering. - * @default `({ transactionNumber }) => transactionNumber + 1` - */ - selector?: ( - editor: Editor, - options: { - /** - * The previous value returned by the selector. - */ - previousValue: TSelectorResult | null; - /** - * The current transaction number. Incremented on every transaction. - */ - transactionNumber: number; - } - ) => TSelectorResult; - /** - * A custom equality function to determine if the editor should re-render. - * @default `(a, b) => a === b` + * Whether to re-render the editor on each transaction. + * This is legacy behavior that will be removed in future versions. + * @default true */ - equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => boolean; + shouldRerenderOnTransaction?: 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 makeEditorInstance({ - immediatelyRender, - options: initialOptions, - selector = (_e, { transactionNumber }) => (transactionNumber + 1) as unknown as TSelectorResult, - equalityFn = (a: TSelectorResult, b: TSelectorResult | null) => a === b, -}: Pick, 'selector' | 'equalityFn' | 'immediatelyRender'> & { - options: Partial; -}) { - let editor: Editor | null = null - let transactionNumber = 0 - let prevSnapshot: [Editor | null, TSelectorResult | null] = [editor, null] - const subscribers = new Set<() => void>() - - const editorInstance = { - /** - * Get the current editor instance. - */ - getSnapshot() { - if (!editor) { - return prevSnapshot - } - - const nextSnapshotResult = selector(editor, { - previousValue: prevSnapshot[1], - transactionNumber, - }) - - if (equalityFn(nextSnapshotResult, prevSnapshot[1])) { - return prevSnapshot - } - - const nextSnapshot: [Editor, TSelectorResult | null] = [editor, nextSnapshotResult] - - prevSnapshot = nextSnapshot - return nextSnapshot - }, - /** - * Always disable the editor on the server-side. - */ - getServerSnapshot(): [null, null] { - return [null, null] - }, - /** - * Subscribe to the editor instance's changes. - */ - subscribe(callback: () => void) { - subscribers.add(callback) - return () => { - subscribers.delete(callback) - } - }, - /** - * Create the editor instance. - */ - create(options: Partial) { - if (editor) { - editor.destroy() - } - editor = new Editor(options) - subscribers.forEach(callback => callback()) - - 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. - */ - editor.on('transaction', () => { - transactionNumber += 1 - subscribers.forEach(callback => callback()) - }) - } - }, - /** - * Destroy the editor instance. - */ - destroy(): void { - if (editor) { - // We need to destroy the editor asynchronously to avoid memory leaks - // because the editor instance is still being used in the component. - const editorToDestroy = editor - - setTimeout(() => editorToDestroy.destroy()) - } - editor = null - }, - } - - if (immediatelyRender) { - editorInstance.create(initialOptions) - } - - return editorInstance -} - /** * This hook allows you to create an editor instance. * @param options The editor options @@ -151,10 +36,10 @@ function makeEditorInstance({ * @returns The editor instance * @example const editor = useEditor({ extensions: [...] }) */ -function useEditorWithState( - options: UseEditorOptions & { immediatelyRender: true }, +export function useEditor( + options: UseEditorOptions & { immediatelyRender: true }, deps?: DependencyList -): { editor: Editor; state: TSelectorResult | null }; +): Editor; /** * This hook allows you to create an editor instance. @@ -163,23 +48,16 @@ function useEditorWithState( * @returns The editor instance * @example const editor = useEditor({ extensions: [...] }) */ -function useEditorWithState( - options?: UseEditorOptions, +export function useEditor( + options?: UseEditorOptions, deps?: DependencyList -): { editor: Editor | null; state: TSelectorResult | null }; +): Editor | null; -function useEditorWithState( - options: UseEditorOptions = {}, +export function useEditor( + options: UseEditorOptions = {}, deps: DependencyList = [], -): { editor: Editor | null; state: TSelectorResult | null } { - const [editorInstance] = useState(() => { - const instanceCreateOptions: Parameters>[0] = { - immediatelyRender: Boolean(options.immediatelyRender), - equalityFn: options.equalityFn, - selector: options.selector, - options, - } - +): 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 @@ -194,13 +72,11 @@ function useEditorWithState( } // Best faith effort in production, run the code in the legacy mode to avoid hydration mismatches and errors in production - instanceCreateOptions.immediatelyRender = false - return makeEditorInstance(instanceCreateOptions) + return null } - // Default to `true` in client-side rendering - instanceCreateOptions.immediatelyRender = true - return makeEditorInstance(instanceCreateOptions) + // Default to immediately rendering when client-side rendering + return new Editor(options) } if (options.immediatelyRender && isSSR && isDev) { @@ -210,27 +86,27 @@ function useEditorWithState( ) } - return makeEditorInstance(instanceCreateOptions) - }) + if (options.immediatelyRender) { + return new Editor(options) + } - // Using the `useSyncExternalStore` hook to sync the editor instance with the component state - const [editor, selectedState] = useSyncExternalStore( - editorInstance.subscribe, - editorInstance.getSnapshot, - editorInstance.getServerSnapshot, - ) + return null + }) useDebugValue(editor) // This effect will handle creating/updating the editor instance useEffect(() => { - if (!editor) { + 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 - editorInstance.create(options) + setEditor(editorInstance) } else { // if the editor does exist, update the editor options accordingly - editor.setOptions(options) + editorInstance.setOptions(options) } }, deps) @@ -345,42 +221,27 @@ function useEditorWithState( * */ useEffect(() => { return () => { - editorInstance.destroy() + if (editor) { + // We need to destroy the editor asynchronously to avoid memory leaks + // because the editor instance is still being used in the component. + + setTimeout(() => (editor.isDestroyed ? null : editor.destroy())) + } } }, []) - return { editor, state: selectedState } -} - -/** - * 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: [...] }) - */ -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: [...] }) - */ -function useEditor( - options?: UseEditorOptions, - deps?: DependencyList -): Editor | null; + // 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 + } + return transactionNumber + 1 + }, + }) -function useEditor( - options: UseEditorOptions = {}, - deps: DependencyList = [], -): Editor | null { - return useEditorWithState(options, deps).editor + return editor } - -export { useEditor, useEditorWithState } diff --git a/packages/react/src/useEditorState.ts b/packages/react/src/useEditorState.ts new file mode 100644 index 0000000000..edbfc81ed4 --- /dev/null +++ b/packages/react/src/useEditorState.ts @@ -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 = { + 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) => 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(initialEditor: TEditor) { + let transactionNumber = 0 + let lastTransactionNumber = 0 + let lastSnapshot: EditorStateSnapshot = { editor: initialEditor, transactionNumber: 0 } + let editor = initialEditor + const subscribers = new Set<() => void>() + + const editorInstance = { + /** + * Get the current editor instance. + */ + getSnapshot(): EditorStateSnapshot { + if (transactionNumber === lastTransactionNumber) { + return lastSnapshot + } + lastTransactionNumber = transactionNumber + lastSnapshot = { editor, transactionNumber } + return lastSnapshot + }, + /** + * Always disable the editor on the server-side. + */ + getServerSnapshot(): EditorStateSnapshot { + 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( + options: UseEditorStateOptions +): TSelectorResult | null; +export function useEditorState( + options: UseEditorStateOptions +): TSelectorResult; + +export function useEditorState( + options: UseEditorStateOptions | UseEditorStateOptions, +): 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['selector'], + options.equalityFn, + ) + + useEffect(() => { + return editorInstance.watch(options.editor) + }, [options.editor]) + + useDebugValue(selectedState) + + return selectedState +}