@tiptap/react 2.5.7 → 2.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/useEditor.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { EditorOptions } from '@tiptap/core'
2
2
  import {
3
- DependencyList, useDebugValue, useEffect, useRef, useState,
3
+ DependencyList,
4
+ MutableRefObject,
5
+ useDebugValue,
6
+ useEffect,
7
+ useRef,
8
+ useState,
4
9
  } from 'react'
10
+ import { useSyncExternalStore } from 'use-sync-external-store/shim'
5
11
 
6
12
  import { Editor } from './Editor.js'
7
13
  import { useEditorState } from './useEditorState.js'
@@ -30,36 +36,69 @@ export type UseEditorOptions = Partial<EditorOptions> & {
30
36
  };
31
37
 
32
38
  /**
33
- * This hook allows you to create an editor instance.
34
- * @param options The editor options
35
- * @param deps The dependencies to watch for changes
36
- * @returns The editor instance
37
- * @example const editor = useEditor({ extensions: [...] })
39
+ * This class handles the creation, destruction, and re-creation of the editor instance.
38
40
  */
39
- export function useEditor(
40
- options: UseEditorOptions & { immediatelyRender: true },
41
- deps?: DependencyList
42
- ): Editor;
41
+ class EditorInstanceManager {
42
+ /**
43
+ * The current editor instance.
44
+ */
45
+ private editor: Editor | null = null
43
46
 
44
- /**
45
- * This hook allows you to create an editor instance.
46
- * @param options The editor options
47
- * @param deps The dependencies to watch for changes
48
- * @returns The editor instance
49
- * @example const editor = useEditor({ extensions: [...] })
50
- */
51
- export function useEditor(
52
- options?: UseEditorOptions,
53
- deps?: DependencyList
54
- ): Editor | null;
47
+ /**
48
+ * The most recent options to apply to the editor.
49
+ */
50
+ private options: MutableRefObject<UseEditorOptions>
55
51
 
56
- export function useEditor(
57
- options: UseEditorOptions = {},
58
- deps: DependencyList = [],
59
- ): Editor | null {
60
- const isMounted = useRef(false)
61
- const [editor, setEditor] = useState(() => {
62
- if (options.immediatelyRender === undefined) {
52
+ /**
53
+ * The subscriptions to notify when the editor instance
54
+ * has been created or destroyed.
55
+ */
56
+ private subscriptions = new Set<() => void>()
57
+
58
+ /**
59
+ * A timeout to destroy the editor if it was not mounted within a time frame.
60
+ */
61
+ private scheduledDestructionTimeout: ReturnType<typeof setTimeout> | undefined
62
+
63
+ /**
64
+ * Whether the editor has been mounted.
65
+ */
66
+ private isComponentMounted = false
67
+
68
+ /**
69
+ * The most recent dependencies array.
70
+ */
71
+ private previousDeps: DependencyList | null = null
72
+
73
+ /**
74
+ * The unique instance ID. This is used to identify the editor instance. And will be re-generated for each new instance.
75
+ */
76
+ public instanceId = ''
77
+
78
+ constructor(options: MutableRefObject<UseEditorOptions>) {
79
+ this.options = options
80
+ this.subscriptions = new Set<() => void>()
81
+ this.setEditor(this.getInitialEditor())
82
+
83
+ this.getEditor = this.getEditor.bind(this)
84
+ this.getServerSnapshot = this.getServerSnapshot.bind(this)
85
+ this.subscribe = this.subscribe.bind(this)
86
+ this.refreshEditorInstance = this.refreshEditorInstance.bind(this)
87
+ this.scheduleDestroy = this.scheduleDestroy.bind(this)
88
+ this.onRender = this.onRender.bind(this)
89
+ this.createEditor = this.createEditor.bind(this)
90
+ }
91
+
92
+ private setEditor(editor: Editor | null) {
93
+ this.editor = editor
94
+ this.instanceId = Math.random().toString(36).slice(2, 9)
95
+
96
+ // Notify all subscribers that the editor instance has been created
97
+ this.subscriptions.forEach(cb => cb())
98
+ }
99
+
100
+ private getInitialEditor() {
101
+ if (this.options.current.immediatelyRender === undefined) {
63
102
  if (isSSR || isNext) {
64
103
  // TODO in the next major release, we should throw an error here
65
104
  if (isDev) {
@@ -77,178 +116,205 @@ export function useEditor(
77
116
  }
78
117
 
79
118
  // Default to immediately rendering when client-side rendering
80
- return new Editor(options)
119
+ return this.createEditor()
81
120
  }
82
121
 
83
- if (options.immediatelyRender && isSSR && isDev) {
122
+ if (this.options.current.immediatelyRender && isSSR && isDev) {
84
123
  // Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches.
85
124
  throw new Error(
86
125
  '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.',
87
126
  )
88
127
  }
89
128
 
90
- if (options.immediatelyRender) {
91
- return new Editor(options)
129
+ if (this.options.current.immediatelyRender) {
130
+ return this.createEditor()
92
131
  }
93
132
 
94
133
  return null
95
- })
134
+ }
96
135
 
97
- useDebugValue(editor)
136
+ /**
137
+ * Create a new editor instance. And attach event listeners.
138
+ */
139
+ private createEditor(): Editor {
140
+ const editor = new Editor(this.options.current)
98
141
 
99
- // This effect will handle creating/updating the editor instance
100
- useEffect(() => {
101
- let editorInstance: Editor | null = editor
102
-
103
- if (!editorInstance) {
104
- editorInstance = new Editor(options)
105
- // instantiate the editor if it doesn't exist
106
- // for ssr, this is the first time the editor is created
107
- setEditor(editorInstance)
108
- } else if (Array.isArray(deps) && deps.length) {
109
- // We need to destroy the editor instance and re-initialize it
110
- // when the deps array changes
111
- editorInstance.destroy()
112
-
113
- // the deps array is used to re-initialize the editor instance
114
- editorInstance = new Editor(options)
115
-
116
- setEditor(editorInstance)
117
- } else {
118
- // if the editor does exist & deps are empty, we don't need to re-initialize the editor
119
- // we can fast-path to update the editor options on the existing instance
120
- editorInstance.setOptions(options)
121
- }
122
- }, deps)
123
-
124
- const {
125
- onBeforeCreate,
126
- onBlur,
127
- onCreate,
128
- onDestroy,
129
- onFocus,
130
- onSelectionUpdate,
131
- onTransaction,
132
- onUpdate,
133
- onContentError,
134
- } = options
135
-
136
- const onBeforeCreateRef = useRef(onBeforeCreate)
137
- const onBlurRef = useRef(onBlur)
138
- const onCreateRef = useRef(onCreate)
139
- const onDestroyRef = useRef(onDestroy)
140
- const onFocusRef = useRef(onFocus)
141
- const onSelectionUpdateRef = useRef(onSelectionUpdate)
142
- const onTransactionRef = useRef(onTransaction)
143
- const onUpdateRef = useRef(onUpdate)
144
- const onContentErrorRef = useRef(onContentError)
145
-
146
- // This effect will handle updating the editor instance
147
- // when the event handlers change.
148
- useEffect(() => {
149
- if (!editor) {
150
- return
151
- }
142
+ // Always call the most recent version of the callback function by default
143
+ editor.on('beforeCreate', (...args) => this.options.current.onBeforeCreate?.(...args))
144
+ editor.on('blur', (...args) => this.options.current.onBlur?.(...args))
145
+ editor.on('create', (...args) => this.options.current.onCreate?.(...args))
146
+ editor.on('destroy', (...args) => this.options.current.onDestroy?.(...args))
147
+ editor.on('focus', (...args) => this.options.current.onFocus?.(...args))
148
+ editor.on('selectionUpdate', (...args) => this.options.current.onSelectionUpdate?.(...args))
149
+ editor.on('transaction', (...args) => this.options.current.onTransaction?.(...args))
150
+ editor.on('update', (...args) => this.options.current.onUpdate?.(...args))
151
+ editor.on('contentError', (...args) => this.options.current.onContentError?.(...args))
152
152
 
153
- if (onBeforeCreate) {
154
- editor.off('beforeCreate', onBeforeCreateRef.current)
155
- editor.on('beforeCreate', onBeforeCreate)
153
+ // no need to keep track of the event listeners, they will be removed when the editor is destroyed
156
154
 
157
- onBeforeCreateRef.current = onBeforeCreate
158
- }
155
+ return editor
156
+ }
159
157
 
160
- if (onBlur) {
161
- editor.off('blur', onBlurRef.current)
162
- editor.on('blur', onBlur)
158
+ /**
159
+ * Get the current editor instance.
160
+ */
161
+ getEditor(): Editor | null {
162
+ return this.editor
163
+ }
163
164
 
164
- onBlurRef.current = onBlur
165
- }
165
+ /**
166
+ * Always disable the editor on the server-side.
167
+ */
168
+ getServerSnapshot(): null {
169
+ return null
170
+ }
166
171
 
167
- if (onCreate) {
168
- editor.off('create', onCreateRef.current)
169
- editor.on('create', onCreate)
172
+ /**
173
+ * Subscribe to the editor instance's changes.
174
+ */
175
+ subscribe(onStoreChange: () => void) {
176
+ this.subscriptions.add(onStoreChange)
170
177
 
171
- onCreateRef.current = onCreate
178
+ return () => {
179
+ this.subscriptions.delete(onStoreChange)
172
180
  }
181
+ }
173
182
 
174
- if (onDestroy) {
175
- editor.off('destroy', onDestroyRef.current)
176
- editor.on('destroy', onDestroy)
183
+ /**
184
+ * On each render, we will create, update, or destroy the editor instance.
185
+ * @param deps The dependencies to watch for changes
186
+ * @returns A cleanup function
187
+ */
188
+ onRender(deps: DependencyList) {
189
+ // The returned callback will run on each render
190
+ return () => {
191
+ this.isComponentMounted = true
192
+ // Cleanup any scheduled destructions, since we are currently rendering
193
+ clearTimeout(this.scheduledDestructionTimeout)
194
+
195
+ if (this.editor && !this.editor.isDestroyed && deps.length === 0) {
196
+ // if the editor does exist & deps are empty, we don't need to re-initialize the editor
197
+ // we can fast-path to update the editor options on the existing instance
198
+ this.editor.setOptions(this.options.current)
199
+ } else {
200
+ // When the editor:
201
+ // - does not yet exist
202
+ // - is destroyed
203
+ // - the deps array changes
204
+ // We need to destroy the editor instance and re-initialize it
205
+ this.refreshEditorInstance(deps)
206
+ }
177
207
 
178
- onDestroyRef.current = onDestroy
208
+ return () => {
209
+ this.isComponentMounted = false
210
+ this.scheduleDestroy()
211
+ }
179
212
  }
213
+ }
180
214
 
181
- if (onFocus) {
182
- editor.off('focus', onFocusRef.current)
183
- editor.on('focus', onFocus)
215
+ /**
216
+ * Recreate the editor instance if the dependencies have changed.
217
+ */
218
+ private refreshEditorInstance(deps: DependencyList) {
219
+
220
+ if (this.editor && !this.editor.isDestroyed) {
221
+ // Editor instance already exists
222
+ if (this.previousDeps === null) {
223
+ // If lastDeps has not yet been initialized, reuse the current editor instance
224
+ this.previousDeps = deps
225
+ return
226
+ }
227
+ const depsAreEqual = this.previousDeps.length === deps.length
228
+ && this.previousDeps.every((dep, index) => dep === deps[index])
184
229
 
185
- onFocusRef.current = onFocus
230
+ if (depsAreEqual) {
231
+ // deps exist and are equal, no need to recreate
232
+ return
233
+ }
186
234
  }
187
235
 
188
- if (onSelectionUpdate) {
189
- editor.off('selectionUpdate', onSelectionUpdateRef.current)
190
- editor.on('selectionUpdate', onSelectionUpdate)
191
-
192
- onSelectionUpdateRef.current = onSelectionUpdate
236
+ if (this.editor && !this.editor.isDestroyed) {
237
+ // Destroy the editor instance if it exists
238
+ this.editor.destroy()
193
239
  }
194
240
 
195
- if (onTransaction) {
196
- editor.off('transaction', onTransactionRef.current)
197
- editor.on('transaction', onTransaction)
241
+ this.setEditor(this.createEditor())
198
242
 
199
- onTransactionRef.current = onTransaction
200
- }
243
+ // Update the lastDeps to the current deps
244
+ this.previousDeps = deps
245
+ }
201
246
 
202
- if (onUpdate) {
203
- editor.off('update', onUpdateRef.current)
204
- editor.on('update', onUpdate)
247
+ /**
248
+ * Schedule the destruction of the editor instance.
249
+ * This will only destroy the editor if it was not mounted on the next tick.
250
+ * This is to avoid destroying the editor instance when it's actually still mounted.
251
+ */
252
+ private scheduleDestroy() {
253
+ const currentInstanceId = this.instanceId
254
+ const currentEditor = this.editor
255
+
256
+ // Wait a tick to see if the component is still mounted
257
+ this.scheduledDestructionTimeout = setTimeout(() => {
258
+ if (this.isComponentMounted && this.instanceId === currentInstanceId) {
259
+ // If still mounted on the next tick, with the same instanceId, do not destroy the editor
260
+ if (currentEditor) {
261
+ // just re-apply options as they might have changed
262
+ currentEditor.setOptions(this.options.current)
263
+ }
264
+ return
265
+ }
266
+ if (currentEditor && !currentEditor.isDestroyed) {
267
+ currentEditor.destroy()
268
+ if (this.instanceId === currentInstanceId) {
269
+ this.setEditor(null)
270
+ }
271
+ }
272
+ }, 0)
273
+ }
274
+ }
205
275
 
206
- onUpdateRef.current = onUpdate
207
- }
276
+ /**
277
+ * This hook allows you to create an editor instance.
278
+ * @param options The editor options
279
+ * @param deps The dependencies to watch for changes
280
+ * @returns The editor instance
281
+ * @example const editor = useEditor({ extensions: [...] })
282
+ */
283
+ export function useEditor(
284
+ options: UseEditorOptions & { immediatelyRender: true },
285
+ deps?: DependencyList
286
+ ): Editor;
208
287
 
209
- if (onContentError) {
210
- editor.off('contentError', onContentErrorRef.current)
211
- editor.on('contentError', onContentError)
288
+ /**
289
+ * This hook allows you to create an editor instance.
290
+ * @param options The editor options
291
+ * @param deps The dependencies to watch for changes
292
+ * @returns The editor instance
293
+ * @example const editor = useEditor({ extensions: [...] })
294
+ */
295
+ export function useEditor(options?: UseEditorOptions, deps?: DependencyList): Editor | null;
212
296
 
213
- onContentErrorRef.current = onContentError
214
- }
215
- }, [
216
- onBeforeCreate,
217
- onBlur,
218
- onCreate,
219
- onDestroy,
220
- onFocus,
221
- onSelectionUpdate,
222
- onTransaction,
223
- onUpdate,
224
- onContentError,
225
- editor,
226
- ])
297
+ export function useEditor(
298
+ options: UseEditorOptions = {},
299
+ deps: DependencyList = [],
300
+ ): Editor | null {
301
+ const mostRecentOptions = useRef(options)
227
302
 
228
- /**
229
- * Destroy the editor instance when the component completely unmounts
230
- * As opposed to the cleanup function in the effect above, this will
231
- * only be called when the component is removed from the DOM, since it has no deps.
232
- * */
233
- useEffect(() => {
234
- isMounted.current = true
235
- return () => {
236
- isMounted.current = false
237
- if (editor) {
238
- // We need to destroy the editor asynchronously to avoid memory leaks
239
- // because the editor instance is still being used in the component.
240
-
241
- setTimeout(() => {
242
- // re-use the editor instance if it hasn't been destroyed yet
243
- // and the component is still mounted
244
- // otherwise, asynchronously destroy the editor instance
245
- if (!isMounted.current && !editor.isDestroyed) {
246
- editor.destroy()
247
- }
248
- })
249
- }
250
- }
251
- }, [])
303
+ mostRecentOptions.current = options
304
+
305
+ const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions))
306
+
307
+ const editor = useSyncExternalStore(
308
+ instanceManager.subscribe,
309
+ instanceManager.getEditor,
310
+ instanceManager.getServerSnapshot,
311
+ )
312
+
313
+ useDebugValue(editor)
314
+
315
+ // This effect will handle creating/updating the editor instance
316
+ // eslint-disable-next-line react-hooks/exhaustive-deps
317
+ useEffect(instanceManager.onRender(deps))
252
318
 
253
319
  // The default behavior is to re-render on each transaction
254
320
  // This is legacy behavior that will be removed in future versions
@@ -30,68 +30,83 @@ export type UseEditorStateOptions<
30
30
  * To synchronize the editor instance with the component state,
31
31
  * we need to create a separate instance that is not affected by the component re-renders.
32
32
  */
33
- function makeEditorStateInstance<TEditor extends Editor | null = Editor | null>(initialEditor: TEditor) {
34
- let transactionNumber = 0
35
- let lastTransactionNumber = 0
36
- let lastSnapshot: EditorStateSnapshot<TEditor> = { editor: initialEditor, transactionNumber: 0 }
37
- let editor = initialEditor
38
- const subscribers = new Set<() => void>()
39
-
40
- const editorInstance = {
41
- /**
42
- * Get the current editor instance.
43
- */
44
- getSnapshot(): EditorStateSnapshot<TEditor> {
45
- if (transactionNumber === lastTransactionNumber) {
46
- return lastSnapshot
33
+ class EditorStateManager<TEditor extends Editor | null = Editor | null> {
34
+ private transactionNumber = 0
35
+
36
+ private lastTransactionNumber = 0
37
+
38
+ private lastSnapshot: EditorStateSnapshot<TEditor>
39
+
40
+ private editor: TEditor
41
+
42
+ private subscribers = new Set<() => void>()
43
+
44
+ constructor(initialEditor: TEditor) {
45
+ this.editor = initialEditor
46
+ this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 }
47
+
48
+ this.getSnapshot = this.getSnapshot.bind(this)
49
+ this.getServerSnapshot = this.getServerSnapshot.bind(this)
50
+ this.watch = this.watch.bind(this)
51
+ this.subscribe = this.subscribe.bind(this)
52
+ }
53
+
54
+ /**
55
+ * Get the current editor instance.
56
+ */
57
+ getSnapshot(): EditorStateSnapshot<TEditor> {
58
+ if (this.transactionNumber === this.lastTransactionNumber) {
59
+ return this.lastSnapshot
60
+ }
61
+ this.lastTransactionNumber = this.transactionNumber
62
+ this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber }
63
+ return this.lastSnapshot
64
+ }
65
+
66
+ /**
67
+ * Always disable the editor on the server-side.
68
+ */
69
+ getServerSnapshot(): EditorStateSnapshot<null> {
70
+ return { editor: null, transactionNumber: 0 }
71
+ }
72
+
73
+ /**
74
+ * Subscribe to the editor instance's changes.
75
+ */
76
+ subscribe(callback: () => void): () => void {
77
+ this.subscribers.add(callback)
78
+ return () => {
79
+ this.subscribers.delete(callback)
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Watch the editor instance for changes.
85
+ */
86
+ watch(nextEditor: Editor | null): undefined | (() => void) {
87
+ this.editor = nextEditor as TEditor
88
+
89
+ if (this.editor) {
90
+ /**
91
+ * This will force a re-render when the editor state changes.
92
+ * This is to support things like `editor.can().toggleBold()` in components that `useEditor`.
93
+ * This could be more efficient, but it's a good trade-off for now.
94
+ */
95
+ const fn = () => {
96
+ this.transactionNumber += 1
97
+ this.subscribers.forEach(callback => callback())
47
98
  }
48
- lastTransactionNumber = transactionNumber
49
- lastSnapshot = { editor, transactionNumber }
50
- return lastSnapshot
51
- },
52
- /**
53
- * Always disable the editor on the server-side.
54
- */
55
- getServerSnapshot(): EditorStateSnapshot<null> {
56
- return { editor: null, transactionNumber: 0 }
57
- },
58
- /**
59
- * Subscribe to the editor instance's changes.
60
- */
61
- subscribe(callback: () => void) {
62
- subscribers.add(callback)
99
+
100
+ const currentEditor = this.editor
101
+
102
+ currentEditor.on('transaction', fn)
63
103
  return () => {
64
- subscribers.delete(callback)
104
+ currentEditor.off('transaction', fn)
65
105
  }
66
- },
67
- /**
68
- * Watch the editor instance for changes.
69
- */
70
- watch(nextEditor: Editor | null) {
71
- editor = nextEditor as TEditor
72
-
73
- if (editor) {
74
- /**
75
- * This will force a re-render when the editor state changes.
76
- * This is to support things like `editor.can().toggleBold()` in components that `useEditor`.
77
- * This could be more efficient, but it's a good trade-off for now.
78
- */
79
- const fn = () => {
80
- transactionNumber += 1
81
- subscribers.forEach(callback => callback())
82
- }
83
-
84
- const currentEditor = editor
85
-
86
- currentEditor.on('transaction', fn)
87
- return () => {
88
- currentEditor.off('transaction', fn)
89
- }
90
- }
91
- },
92
- }
106
+ }
93
107
 
94
- return editorInstance
108
+ return undefined
109
+ }
95
110
  }
96
111
 
97
112
  export function useEditorState<TSelectorResult>(
@@ -104,7 +119,7 @@ export function useEditorState<TSelectorResult>(
104
119
  export function useEditorState<TSelectorResult>(
105
120
  options: UseEditorStateOptions<TSelectorResult, Editor> | UseEditorStateOptions<TSelectorResult, Editor | null>,
106
121
  ): TSelectorResult | null {
107
- const [editorInstance] = useState(() => makeEditorStateInstance(options.editor))
122
+ const [editorInstance] = useState(() => new EditorStateManager(options.editor))
108
123
 
109
124
  // Using the `useSyncExternalStore` hook to sync the editor instance with the component state
110
125
  const selectedState = useSyncExternalStoreWithSelector(
@@ -117,7 +132,7 @@ export function useEditorState<TSelectorResult>(
117
132
 
118
133
  useEffect(() => {
119
134
  return editorInstance.watch(options.editor)
120
- }, [options.editor])
135
+ }, [options.editor, editorInstance])
121
136
 
122
137
  useDebugValue(selectedState)
123
138