-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Bug]: Editor options set on every render #6024
Comments
A simple workaround for now is to avoid that code path by adding dummy deps const editor = useEditor({...}, [1]) |
At a cursory glance, this looks like good low-hanging fruit. The workaround leads to |
@Nantris Editor instance does not refresh if deps are unchanged |
@yurtsiv I think you misunderstand me. I don't even think this much cheaper function needs to be called. If that's right, then we could do better still than your workaround with a proper fix. tiptap/packages/react/src/useEditor.ts Line 212 in 839eb47
|
So, what should we do here? I would prefer to avoid doing a diff on every render, because if anyone has a function as a prop, then it is likely to always trigger anyway and the diff would be useless. In most cases, setting options is essentially free (at least compared to a react render). Maybe we can come up with some criteria for an early exit within setOptions |
I agree, it would be no good having the users who wouldn't benefit from this micro-optimization doing extra work for those who would. Just spitballing here, but I wonder if it might be reasonable to do something along the lines of how It's a tiny bit better than the current workaround and has the benefit of then being documented. An automatic solution is still preferrable but it doesn't jump out to me. @yurtsiv I wonder how you noticed this in the first place? It's a good find! But I can't imagine it actually slowed anything down enough that you found it that way, right? |
There's another issue: pending state updates could change options and there is no way around in this scenario, editor options would have to be updated. Early returns (including option diffing) can only partially solve the problem. I'm not sure how this can be fixed in the TipTap itself. Besides dummy dependencies, there are two other solutions:
After removing all dependencies from |
Hm, looking at this again, the silver lining here is that the functions are already optimized in a way that they only ever are registered on editor creation, and when re-applied, they reference the latest versions already. tiptap/packages/react/src/useEditor.ts Lines 133 to 147 in 7b4e6f5
So, it looks like it would only be diffing some fairly basic values. Since the options object is rather small, I think it should be fine to just do a shallow equals between the keys & values of the known keys (i.e. explicitly excluding the functions so that it is more likely to actually work). |
Here is what I came up with: diff --git a/packages/react/src/useEditor.ts b/packages/react/src/useEditor.ts
index 776840615..2bba04fe0 100644
--- a/packages/react/src/useEditor.ts
+++ b/packages/react/src/useEditor.ts
@@ -197,13 +197,47 @@ class EditorInstanceManager {
clearTimeout(this.scheduledDestructionTimeout)
if (this.editor && !this.editor.isDestroyed && deps.length === 0) {
+ const editor = this.editor
+
// 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,
- editable: this.editor.isEditable,
- })
+ if (Object.keys(this.options.current).some(key => {
+ if (['onCreate', 'onBeforeCreate', 'onDestroy', 'onUpdate', 'onTransaction', 'onFocus', 'onBlur', 'onSelectionUpdate', 'onContentError', 'onDrop', 'onPaste'].includes(key)) {
+ // we don't want to compare callbacks, they are always different and only registered once
+ return false
+ }
+
+ // We often encourage putting extensions inlined in the options object, so we will do a slightly deeper comparison here
+ if (key === 'extensions' && this.options.current.extensions) {
+ if (this.options.current.extensions.length !== editor.options.extensions.length) {
+ return true
+ }
+ return this.options.current.extensions.some((extension, index) => {
+ if (extension !== editor.options.extensions[index]) {
+ console.log('extensions changed', extension, editor.options.extensions[index])
+ return true
+ }
+ return false
+ })
+ }
+ if (this.options.current[key as keyof UseEditorOptions] !== editor.options[key as keyof EditorOptions]) {
+ console.log('options changed', key, this.options.current[key as keyof UseEditorOptions], editor.options[key as keyof EditorOptions])
+ // if any of the options have changed, we should update the editor options
+ return true
+ }
+ return false
+ })) {
+ console.count('fast-ish path')
+
+ // we can fast-path to update the editor options on the existing instance
+ this.editor.setOptions({
+ ...this.options.current,
+ editable: this.editor.isEditable,
+ })
+ } else {
+ console.count('fastest path')
+ }
} else {
+ console.count('slowest path')
// When the editor:
// - does not yet exist
// - is destroyed
|
@nperez0111 |
It's referential equality, so it would be a pointer compare A lot of React people use the controlled component pattern which is very inefficient, but I digress... |
It will degrade to a string compare, if someone built a string that has a different pointer. At which point, shame on them, they deserve the performance hit 😂 |
We will see how it goes, #6031 |
@yurtsiv, would you mind validating the fix with this version please?
From here: #6031 (comment) |
@nperez0111 I was thinking about the controlled component pattern. If you're fine with making it even less efficient, the proposed solution seems fine to me. |
controlled component pattern is very harmful. React has trouble getting those to run even with basic input boxes, much less an entire rich text editor. I would very much advise you against it unless you want to solve performance issues later on. It is much more performant to get the value from the editor instance when you actually need it rather than trying to synchronize it with some state. Like this: DO NOT DO THIS: function Component() {
const [editorContent, setEditorContent] = React.useState(`
<p>This isn’t bold.</p>
<p><strong>This is bold.</strong></p>
`)
const editor = useEditor({
extensions: [Document, Paragraph, Text, Bold],
content: editorContent,
onUpdate: ({ editor }) => {
setEditorContent(editor.getHTML())
},
})
return (
<>
{editorContent}
<button onClick={() => saveToDB(editorContent)}>Send somewhere</button>
</>
)
} DO THIS: function Component() {
const editor = useEditor({
extensions: [Document, Paragraph, Text, Bold],
content: editorContent,
content: `
<p>This isn’t bold.</p>
<p><strong>This is bold.</strong></p>
`,
})
return (
<>
{editor.getHTML()}
<button onClick={() => saveToDB(editor.getHTML())}>Send somewhere</button>
</>
)
} |
Never tried that, was just thinking about other people out there. There probably aren’t many… 😄 |
The patch is working 🎉 Had to memoize |
Seems to work great here. All tests pass. Nice patch @nperez0111! |
#6024 (#6031) This does a shallow diff between the current options and the incoming ones to determine whether we should try to write the new options and incur a state update within the editor. It purposefully is not doing a full diff as several options are known to be problematic (callback handlers, extensions array, the content itself), so we rely on referential equality only to do this diffing which should be fairly fast since there are only about 10-15 options, and this diffs only the ones the user has actually attempted to set.
Released in version 2.11.3 |
But, again, should probably reconsider your usage |
@nperez0111 You mean the usage of |
I was referring to controlled component pattern. But, now that you brought it up. Yea, using the ReactRenderer for a plugin view is definitely not a good pattern and nowhere do we recommend doing that. Plugin views are expected to be run synchronously, we put a lot of work to get it to work for Node Views, but honestly I believe it to be an anti-pattern anyway. If you actually cared about performance or did not want to deal with subtle bugs with asynchronicity then I'd recommend you to just write the plain JS to create whatever view you are trying to make |
I fixed this issue more out of the potential perf benefits rather than actually endorsing this sort of usage. |
Writing the view in plain JS is not feasible in my case. There're no performance issues however, it's rendered only once and floats around on each transaction. As for the controlled component pattern, I don't use it. Thanks for all the help, I appreciate that! |
Affected Packages
react
Version(s)
2.11.2
Bug Description
editor.setOptions
is being called on each render in case of empty deps.The bug I ran into is quite obscure but there could be more problems.
Scenario:
Calling
onAction
will result influshSync was called from inside a lifecycle method.
error.Event chain:
ReactRenderer
callsflushSync
Editor
component gets re-rendered because of the new stateuseEditor
callseditor.setOptions
which again triggers the plugin view updateI believe editor options should be updated only when they change.
Browser Used
Chrome
Code Example URL
No response
Expected Behavior
No error is reported.
Additional Context (Optional)
No response
Dependency Updates
The text was updated successfully, but these errors were encountered: