tiptap: onUpdate callback does not update after re-render
What’s the bug you are facing?
I have a wrapper around tiptap, whenever the text changes I trigger a request to my back-end, this works fine the first time tiptap is mounted but the parent component (where tiptap is mounted) can change its internal variables, and therefore the closure should capture a new context, the problem is that it doesn’t after the parent component changes the state, the closure/lambda passed on the onUpdate function remains the same and therefore tiptap tries to update the wrong component.
Here is some of the code, my high level component on the parent, notice the id param, which is the param that changes at some point:
<Tiptap
onFocus={({ editor }) => editor.commands.blur()}
initialContent={project.notes ? JSON.parse(project.notes) : null}
placeholder="You can add a default checklist in the settings."
className="md:max-w-2xl lg:max-w-none"
onChange={async (e) => {
console.warn("URL PARAM ID", id) // ALWAYS REMAINS THE SAME, THEREFORE CANNOT UPDATE THE PROJECT CORRECTLY
await updateProjectMutation({
id,
notes: JSON.stringify(e),
})
refetch()
}}
ref={tiptapRef}
/>
My internal TIptap implementation, notice the onUpdate function that I’m passing to the useEditor hook:
import Link from "@tiptap/extension-link"
import Placeholder from "@tiptap/extension-placeholder"
import TaskItem from "@tiptap/extension-task-item"
import TaskList from "@tiptap/extension-task-list"
import { BubbleMenu, EditorContent, Extension, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import React, { forwardRef, useImperativeHandle, useState } from "react"
import { useBoolean } from "../hooks/useBoolean"
import { Button } from "./Button"
interface IProps {
editable?: boolean
onClick?: (this: unknown, view: any, pos: number, event: MouseEvent) => boolean
initialContent?: any
// content?: any
onChange?: (content: any) => void
autofocus?: boolean | null | "end" | "start"
onFocus?: (params: { editor: any }) => void
placeholder?: string
className?: string
}
export const Tiptap = forwardRef<any, IProps>(
(
{
editable = true,
onClick,
initialContent,
onChange,
autofocus,
onFocus,
placeholder,
className,
// content,
},
ref
) => {
const [isAddingLink, addLinkOn, addLinkOff] = useBoolean()
const [link, setLink] = useState("")
const editor = useEditor({
autofocus,
onFocus: onFocus ? onFocus : () => {},
editorProps: {
attributes: {
class: "prose focus:outline-none dark:prose-dark dark:text-gray-300 text-base",
},
editable: () => editable,
handleClick: onClick,
},
content: initialContent,
onUpdate: ({ editor }) => {
onChange?.(editor.getJSON())
},
extensions: [
StarterKit,
Placeholder.configure({
showOnlyWhenEditable: false,
placeholder,
}),
TaskList.configure({
HTMLAttributes: {
class: "pl-0",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "before:hidden pl-0 flex items-center dumb-prose-remove",
},
}),
Extension.create({
// Do not insert line break when pressing CMD+Enter
// Most of the time handled by upper components
addKeyboardShortcuts() {
return {
"Cmd-Enter"() {
return true
},
"Ctrl-Enter"() {
return true
},
}
},
}),
Link,
],
})
useImperativeHandle(ref, () => ({
getEditorInstance() {
return editor
},
}))
return (
<EditorContent editor={editor} className={className} />
)
}
)
In any case, it seems the useEditor hook saves only the first passed onUpdate function and does not update it in sub-sequent renders
How can we reproduce the bug on our side?
Attached the code above, but if necessary I can try to reproduce the issue in a code sandbox
Can you provide a CodeSandbox?
No response
What did you expect to happen?
The passed callback onUpdate should be updated when a new value is passed to it, instead of constantly re-using the first memoized value
Anything to add? (optional)
I tried to update tiptap to the latest version but then I faced this other crash: https://github.com/ueberdosis/tiptap/issues/577 so I reverted to my old/current versions
"@tiptap/extension-bubble-menu": "2.0.0-beta.51",
"@tiptap/extension-link": "2.0.0-beta.33",
"@tiptap/extension-placeholder": "2.0.0-beta.45",
"@tiptap/extension-task-item": "2.0.0-beta.30",
"@tiptap/extension-task-list": "2.0.0-beta.24",
"@tiptap/react": "2.0.0-beta.98",
"@tiptap/starter-kit": "2.0.0-beta.154",
Did you update your dependencies?
- Yes, I’ve updated my dependencies to use the latest version of all packages.
Are you sponsoring us?
- Yes, I’m a sponsor. 💖
About this issue
- Original URL
- State: closed
- Created 2 years ago
- Reactions: 12
- Comments: 35 (3 by maintainers)
yarn cache cleanandnode_modules+yarn.lockdelete andyarn install. this may solve the problem.I just ran into the same issue, it caught me completely off guard. @ospfranco I’ve found this seems to work too. Not sure if it’s the right / best way to use the
off/onmethods. 🤷This was very hard to find. Please add this to the quickStart docs?
So I had the same issue as everyone here, this is how I fixed it in the end:
So small clarifications… the importatant thing is this:
I’ve added a “dependency” to the state of the content of my Editor. So everytime I do “onBlur” the function will be called.
Also I’ve added a useEffect which just updates the state value when the data loads. The tricky part was understanding that the “Dependency” in this case
editorContentactually triggers after onBlur in this case. At least thats how I understood it, please correct me if I’m wrong.When I used onUpdate I always lost focus on the Editor and had to write letter by letter and click on the input everytime, but with “onBlur” that is not the case anymore
Hey everyone 👋,
I just want to share how we are handling this ourselves, and this is something we did from the beginning and never noticed any issues:
RichTextEditor) arounduseEditorand<EditorContent>(just like the OP example)useEditoris added to the dependency arrayuseEditorhook to make sure that the value returned is nevernull(this is not SSR compatible):useMemowhere appropriate, for instance, ourRichTextEditorcomponent takes anextensionsarray to feed intouseEditor, and sinceextensionsis on the dependencies array, we need to memoize this value (rules of hooks)useEventCallbackinstead ofuseCallbackfor every callback (e.g.,onCreate,onUpdate,onSelectionUpdate,onTransaction, etc.)useImperativeHandleto expose the internaleditorinstance to parent componentsAnd that’s pretty much it… We don’t seem to observe any flickers, every callback seems to work as expected, and everything is updated when we need it.
Facing new issues now, here is the updated version of my component:
All in all, these seem major issues with tiptap, never thought I would face so many problems by trying to attach a simple onUpdate handler
Hi @philippkuehn I have the similar issue. Here is a minimal codesandbox
Steps to reproduce:
Looking into core
Editorcode I see that subscription only happen inconstructorhttps://github.com/ueberdosis/tiptap/blob/main/packages/core/src/Editor.ts#L89 and ifsetOptionsis used with new handlers, the event emitter contains “stale” handlers because there is no resubscription insetOptions.I have a similar problem where I created an extension that takes a function in (
onSubmit) that gets called when a user pressesMOD+Enter. However, the closure is stale.For whatever reason, I completely did NOT understand the first 5 times I read this. The answer makes me feel stupid for wasting days. I completely neglected to realize this is a hook, just like useEffect. Use the dependency array.
I’ve had the same issue with a stale onUpdate function, but I managed to fix it with this:
So far it seems to work as I want it to 😃
@rfgamaral - would you mind providing a small amount of sample code to show you you can use an external function inside of the onUpdate call? Specifically elaborating on this?
useEventCallback instead of useCallback for every callback (e.g., onCreate, onUpdate, onSelectionUpdate, onTransaction, etc.)When call the effect useEditor, I define the code I want to call in that callback declaration. But when I call other functions inside of onUpdate, those functions have stale data, which I assume means the function itself is stale, but I don’t know.
useEventCallbackand useCallback but have no familiarity with how to use them in this context to force the functions to be able to use fresh data.My editor looks like this
onUpdatealways sends stale data in the form ofprojectDatatohandleChangeIf I addhandleChangeto thedependency arrayof theeditor, it gets the right data, but then it rerenders the editor causing flickering and losing focus and cursor position.Also,
handleChangeis in the parent component, passed as a prop to the editor component, and handle change also is in the form of (which I think does nothing because the editor is sending old data because it doesn’t realize there’s a newer version of the function?Moving the onUpdate part out entirely like this may actually be working with focus remaining and no flickering…
Last one hopefully, I think I finally understood the gentlemen’s comment from above.
This is the
onChangefunction for me, something I made, and used theuseCallbackhook with it so that it doesn’t create MANY instances of the function and cause each press to call it dozens of times.onChangeis in the dependency array of the useEffect that is keeping track of the editor so that theeditor, and theonChangefunction are always in sync. At least I hope so 😅This issues persists inside the addKeyboardShortcuts(). I’m trying to submit data on the click of the Enter button.
I’m struggling so much with this 😭
Here is a demo of how I got callbacks working:
https://codesandbox.io/s/upbeat-hermann-d3dlw5?file=/src/App.js:1634-1670
@joe-pomelo Thanks! That’s what I did as well. Would be great if I could use the default onUpdate method for this tho.
Any updates on this issue? I have the same problem. I cannot use closures inside Tiptap extensions. They are always stale
I need to have a single source of truth for my content outside of Tiptap, especially because I have my own history system for undo/redo. With Tiptap not reacting to
contentchanges, my undo/redo won’t work. If I useuseEditor’s dependency list, I will lose focus at every keystroke, and dealing only withonBluris a no-go for me.autofocusdoesn’t work for me, either.I somehow manage to get Tiptap to react to external
contentchanges, emittingonChangewithonUpdate, and retaining focus. This is the minimal snippet to get this behaviour working.Basically, we track the cursor position so that we can return it later.
setContentwill set the cursor to the last character, hencesetTextSelection. The key is actually thepreserveWhitespace: "full"on bothuseEditorandsetContent. This will make it so that you can actually type spaces at the ends of lines in Tiptap. Otherwise, Tiptap will trim your whitespace, and when you press Space, the cursor moves to the next line (if any) or return back to the last character (trimmed).Since we’re using
useRefto track this, and usingsetContentto synccontent, there shouldn’t be any recreations ofeditor(though I’m too lazy to prove it).If the “cursor restoration” is not to your liking, you can modify
onSelectionUpdateand choose which position to save based oneditor.state.selection.empty, maybe? Go crazy. I find that this set-up works fine for me, for now.On another note, I think Tiptap is a great library with the best DX I’ve seen so far for building rich text editors. But the lack of first-party controlled state support is almost a turn-off for me. I hope we can see a better solution soon, because what I have here is really, really, really hacky 💀
This isn’t ideal either, but is probably better than the editor continuously being recreated, blurred, and then putting the old content into the new editor like is done in https://github.com/ueberdosis/tiptap/issues/2403#issuecomment-1248013862
This way, every time the
onKeyDowncallback changes, we just calleditor.setOptionswith the neweditorProps. This way you don’t need to worry about getting a new instance ofeditoron every render.Ideally, I would like
useEditorto always return the sameeditorinstance with updated content and editorProps, but as it is now it always returns a neweditorinstance.@iandsouza2000 Yes it does indeed persist. I am also trying to submit data on Enter. This seems like an extremely common use case.
@RipaltaOriol so you have different components for each row and the initialization of the editor is in row 0? Or do you just have 1 component and different rows and set the text initially to row0 and want to be able to click other rows and update the text accordingly. Could you provide a codesandbox with the code? Do you mean something like this here?: https://codesandbox.io/s/tiptap-editor-update-text-from-rows-60lci8?file=/src/EditorContainer.tsx
I had exactly the same problem with the TipTap editor. I wanted to create a generic TextEditor component, which I in turn use from several other components. From these other components I wanted to always be able to retrieve the state of the TextEditor component (e.g. to provide adhoc validation), but as has already been written here, the updates within the editor instance were not correctly propagated out to the parent.
I have now found another solution for this. In doing so, I still have a generic TextEditor component, but I exported a method outside the rendering function that returns the useEditor hook and at the same time uses the extensions and options I need. I then have to use this extracted method in my other components and can then use my generic TextEditor component at the same time and have the ability to check the current state at any time.
TextEditor.tsx:
EditorContainer.tsx:
here is my working codesandbox: https://codesandbox.io/s/tiptap-editor-state-between-child-and-parent-component-9m0bkh
I’m also struggling with this. I have a setup similar to the ones above, with a wrapper passing dynamic content down to the RichTextEditor and handling updates. The problem is when content changes, I call the onChange through the onUpdate, it updates Firestore, comes back and causes the editor to lose focus, so I can’t continue typing.
I could call onChange through the onBlur but I don’t want users to lose data if they type and close the tab without blurring. How is everyone handling that flow of content being updated and coming back?
Here is my code for reference:
And the RichEditor instance:
updateNoteDebouncedwill send the note to Firestore, which will then re-render the component above and send the new content (note.body_html) back to the editor, causing the lose of focus.