tiptap
tiptap copied to clipboard
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?
- [X] Yes, I’ve updated my dependencies to use the latest version of all packages.
Are you sponsoring us?
- [ ] Yes, I’m a sponsor. 💖
ok, it seems like I've been using the useEditor
hook very wrong, as you can see I was using an imperative handle to try to set the content of the editor after a parent re-render, but everything needs to go as a dependency in the useEditor
hook and tiptap should re-render itself...
Just as a comment, the examples on the documentation page should mention this more clearly
Anyways, sorry for the confusion and thanks for the package!
Facing new issues now, here is the updated version of my component:
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 { EditorContent, EditorEvents, Extension } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import React, { FC, memo, useState } from "react"
import { useBoolean } from "../hooks/useBoolean"
import { useEditor } from "../hooks/useEditor"
interface IProps {
editable?: boolean
// onClick?: (this: unknown, view: any, pos: number, event: MouseEvent) => boolean
content?: any
onUpdate?: (params: EditorEvents["update"]) => void
autofocus?: boolean | null | "end" | "start"
onFocus?: (params: EditorEvents["focus"]) => void
placeholder?: string
className?: string
}
export const Tiptap: FC<IProps> = memo(
({ editable = true, content, onUpdate, autofocus, onFocus, placeholder, className }) => {
console.warn("tiptap render content", content)
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",
},
},
content,
onUpdate: onUpdate ? onUpdate : () => {},
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,
],
},
[onUpdate, content]
)
return (
<div>
<EditorContent editor={editor} className={className} />
</div>
)
}
)
- I added the onUpdate callback and the content prop to the dependency list, this causes the component to flicker (I assume because it is destroying itself every time the dependency changes)
- I had to comment out the BubbleMenu because that also crashes after the internal gets destroyed
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
I got it working by using refs, which is very inelegant and makes the code quite hard to understand:
const [updateProjectMutation] = useMutation(updateProjectNotes)
const tiptapOnUpdateRef = useRef(async ({ editor }: EditorEvents["update"]) => {
await updateProjectMutation({
id,
notes: JSON.stringify(editor.getJSON()),
})
refetch()
})
useEffect(() => {
if (project) {
const editor: Editor = tiptapRef.current?.getEditorInstance()
if (editor && project.notes) {
editor.commands.clearContent() // Needed because the checkboxes do not update
editor.commands.setContent(JSON.parse(project.notes))
tiptapOnUpdateRef.current = async ({ editor }) => {
await updateProjectMutation({
id,
notes: JSON.stringify(editor.getJSON()),
})
refetch()
}
}
}
}, [id, project])
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
/ on
methods. 🤷
const { onChange } = props;
...
// Don't set onUpdate in here
const editor = useEditor(...);
...
useEffect(() => {
editor.off("update");
editor.on("update", ({ editor: updatedEditor }) => onChange(updatedEditor.getHTML()));
}, [editor, onChange]);
@colindb I assumed setOptions
should do something similar, but it doesn't seem to be working, in any case, both solutions attempt to do the same, I like yours better, since passing refs is somewhat verbose and hard to reason about
I am not sure I understand the problem correctly. Can you set up an absolutely minimal codesandbox for this?
yarn cache clean
and node_modules
+ yarn.lock
delete and yarn install
. this may solve the problem.
Hmm if you mean if callback should get updated, this has nothing to do with the node_modules, I already tried updating the latest version, but it is rather a problem of how the hook + editor instance works
The hook takes an array of dependencies to re-render the editor, in order for the callbacks to be updated one has to declare them in the dependency array of the hook, but this causes other issues, basically, the internal instance of the editor gets destroyed and re-created, this causes flickering issues and what not
The workarounds we found, are basically hard replacing the saved callbacks on the first instantiation of the hook, but it's not ideal because: a) it's not documented (I think) and b) it's just really awkward and hard to understand
Hi @philippkuehn I have the similar issue. Here is a minimal codesandbox
Steps to reproduce:
- Type smth in editor, the message "updated" is logged as expected
- Click resubscribe
- Type smth in editor, the message "updated" is logged but the message "resubscribe updated" is expected.
Looking into core Editor
code I see that subscription only happen in constructor
https://github.com/ueberdosis/tiptap/blob/main/packages/core/src/Editor.ts#L89 and if setOptions
is used with new handlers, the event emitter contains "stale" handlers because there is no resubscription in setOptions
.
I have a similar problem where I created an extension that takes a function in (onSubmit
) that gets called when a user presses MOD+Enter
. However, the closure is stale.
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:
- We have a wrapping component (i.e.
RichTextEditor
) arounduseEditor
and<EditorContent>
(just like the OP example) - We follow rules of hooks strictly, and every dependency of
useEditor
is added to the dependency array - We use a custom
useEditor
hook to make sure that the value returned is nevernull
(this is not SSR compatible):- https://gist.github.com/ryanto/4a431d822a98770c4ca7905d9b7b07da
- Then there are a bunch of things that we do in the consumer side:
- We set the initial content like this:
const contentRef = useRef(initialContent) return <RichTextEditor content={contentRef.current} />
- This makes sure the editor instance is not recreated/rerendered when the content changes (i.e. when a user types in the editor)
- We make sure to use
useMemo
where appropriate, for instance, ourRichTextEditor
component takes anextensions
array to feed intouseEditor
, and sinceextensions
is on the dependencies array, we need to memoize this value (rules of hooks) - We use
useEventCallback
instead ofuseCallback
for every callback (e.g.,onCreate
,onUpdate
,onSelectionUpdate
,onTransaction
, etc.)
- We set the initial content like this:
- We also use
useImperativeHandle
to expose the internaleditor
instance to parent components
And 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.
I faced the same issue and created a codesandbox for it. To test it look at the console and try to select text in the editor.
Expected behavior: be able to continue selecting while onSelectionUpdate
changes.
This seems to be a working workaround for now:
editor.off("selectionUpdate")
editor.on("selectionUpdate", () => ...)
edit: I tested out .setOptions({...})
but it doesn't seem to update the listeners at all, not sure if I should file another bug for that?
https://codesandbox.io/s/gifted-river-1v58kf?file=/src/App.tsx
Your missing the dependencies array in the useEditor
hook. Add at least onChange
in there, and it should work as you expect it.
Don't forget to wrap onChange
with useCallback
on the consumer, otherwise the editor will be recreated with every keystroke.
I didn't get what you mean, I'm using [state]
as a dependency array in useEditor
and it's causing it to rerender the whole editor while the state changes every second, so if you try to select the text you cannot really select it.
I didn't understand where the onChange
you mean is? Can you show your edits in a codesandbox?
This was very hard to find. Please add this to the quickStart docs?
Any updates on this issue? I have the same problem. I cannot use closures inside Tiptap extensions. They are always stale
- We follow rules of hooks strictly, and every dependency of
useEditor
is added to the dependency array ---> OF useEditor
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.
const editor = useEditor(
{
//all the things
},
[valuesEditorNeedsToTrack, valuesEditorNeedsToTrack]
);
@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. useEventCallback
and 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
const editor = useEditor(
{
extensions: [StarterKit, Comment, Underline],
//autofocus: "start", //autofocus: getPosition() || "start",
content: contentRef.current,
onUpdate({ editor }) {
if (commentAction) return;
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
handleChange(editor.getHTML(), documentPosition, projectData); //Stale closures
}, 2500);
},
},
), [timerRef, chapterIdRef.current];
onUpdate
always sends stale data in the form of projectData
to handleChange
If I add handleChange
to the dependency array
of the editor
, it gets the right data, but then it rerenders the editor causing flickering and losing focus and cursor position.
Also, handleChange
is 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?
const updatedData = useCallback(await handleContentChange(projectData) {
},[projectData]);
Moving the onUpdate part out entirely like this may actually be working with focus remaining and no flickering...
useEffect(() => {
const onChange = (editor) => {
if (commentAction) return;
clearTimeout(timerRef.current);
console.log("%cClear", "color: cyan");
timerRef.current = setTimeout(() => {
console.log("%cExecuting", "color: lime");
}, 2500);
};
editor &&
editor.on("update", ({ editor }) =>
onChange(editor)
);
return () => {
editor && editor.off("update", ({ editor }) => onChange(editor));
};
}, [documentPosition, editor, handleChange, projectData]);
Last one hopefully, I think I finally understood the gentlemen's comment from above.
This is the onChange
function for me, something I made, and used the useCallback
hook with it so that it doesn't create MANY instances of the function and cause each press to call it dozens of times.
const onChange = useCallback(({editor}) => {
if (commentAction) return;
clearTimeout(timerRef.current);
console.log("%cClear", "color: cyan");
timerRef.current = setTimeout(() => {
console.log("%cExecuting", "color: lime");
//handleUpdateChapter({ ...chapter, content: editor.getHTML() }); //WORKS RIGHT
handleChange(editor.getHTML(), documentPosition, projectData); //Stale closures
}, 2500);
}, [documentPosition, handleChange, projectData]);
onChange
is in the dependency array of the useEffect that is keeping track of the editor so that the editor
, and the onChange
function are always in sync. At least I hope so 😅
useEffect(() => {
editor && editor.on("update", onChange);
return () => {
editor && editor.off("update", onChange);
};
}, [editor, onChange]);
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:
const editor = useEditor(
{
extensions: [StarterKit, Document, Paragraph, Text, Image, Dropcursor],
content: editorContent,
// onUpdate: ({ editor }) => {
// setEditorContent(editor.getHTML());
// },
onBlur: ({ editor }) => {
setEditorContent(editor.getHTML());
},
},
[editorContent]
);
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 editorContent
actually 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
Any update on this? It seems to still be the case as of today.
Here is a demo of how I got callbacks working:
https://codesandbox.io/s/upbeat-hermann-d3dlw5?file=/src/App.js:1634-1670
Here is a demo of how I got callbacks working:
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.
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:
function RichEditor(props) {
const { hideToolbar, content, onChange } = props;
let editor = useEditor(
{
extensions: [
StarterKit,
TaskList.configure({
HTMLAttributes: {
class: 'task-list',
},
}),
TaskItem.configure({
nested: true,
}),
],
parseOptions: {
preserveWhitespace: true,
},
content: content,
onUpdate({ editor }) {
onChange(editor);
},
},
[content]
);
return (
<>
<EditorContent editor={editor} className='note' />
{!hideToolbar && <MenuBar editor={editor} />}
</>
);
}
And the RichEditor instance:
<RichEditor
hideToolbar={hideToolbar}
content={note.body_html}
onChange={updateNoteDebounced}
/>
updateNoteDebounced
will 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.
I'm struggling so much with this 😭
I've had the same issue with a stale onUpdate function, but I managed to fix it with this:
export default function TipTap({ onChange }) {
// Save the content in a local state
const [theContent, setTheContent] = useState<string>();
const editor = useEditor(
{
// ...
onUpdate: ({ editor }) => {
// Update the local state with the content of the editor
setTheContent(editor.getHTML());
},
// ...
},
);
// Listen for changes to the theContent and call the 'onChange' prop
useEffect(() => {
if (!(theContent && props.onChange)) {
return;
}
props.onChange(theContent);
}, [theContent]);
return <EditorContent editor={editor} />;
}
So far it seems to work as I want it to :)
[content]
Did this work for you? Passing in [content]
as dependency of useEditor
is not doing anything for me. The problem is that I have multiple notes to edit with the TipTapEditor and the contents of the editor should dynamically change depending on what note I select.
So I need to 1. pass note
as the content to update contents for every time I change note and 2. update note when I type in the editor. But it looks like this two functions are stepping in each others toes because when I type something on the editor it runs onUpdate
which re-renders the editor because note
is passed as a dependency.
My code:
const TipTapEditor = ({ note, setNote }) => {
const editor = useEditor(
{
extensions: [StarterKit, Underline],
content: note,
onUpdate({ editor }) {
setNote(editor.getHTML());
},
},
[note]
);
return (
<NotesBorder className="text-editor">
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</NotesBorder>
);
};
export default TipTapEditor;
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:
import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import Placeholder from "@tiptap/extension-placeholder";
import Text from "@tiptap/extension-text";
import { Editor, EditorContent, useEditor } from "@tiptap/react";
import { styled } from "@stitches/react";
const TextEditor = styled("div", {
".text-editor": {
"> p": {
margin: 0,
"&.is-editor-empty:first-child::before": {
color: "grey",
content: "attr(data-placeholder)",
float: "left",
height: 0,
pointerEvents: "none",
},
},
"&[contenteditable=false] > p.is-editor-empty": {
cursor: "pointer",
},
minHeight: "200px"
},
});
export const useTextEditor = (
isInitiallyEditable = true,
content?: string
): Editor | null => {
return useEditor({
extensions: [
Document,
Text,
Paragraph,
Placeholder.configure({
placeholder: "Type to add something …",
showOnlyWhenEditable: false,
})
],
editorProps: {
attributes: {
class: "text-editor",
},
},
content: content ? JSON.parse(content) : null,
editable: isInitiallyEditable,
autofocus: true,
});
};
interface TextEditorProps {
editor: Editor | null;
}
const DefaultTextEditor: React.FC<TextEditorProps> = ({ editor }) => {
return (
<TextEditor>
<EditorContent editor={editor} />
</TextEditor>
);
};
export default DefaultTextEditor;
EditorContainer.tsx:
import DefaultTextEditor, { useTextEditor } from "./TextEditor";
const EditorContainer: React.FC = () => {
const editor = useTextEditor();
return (
<>
<div>is editor empty?: {editor?.isEmpty ? "true" : "false"}</div>
<span>content: {JSON.stringify(editor?.getJSON())}</span>
<DefaultTextEditor editor={editor} />
</>
);
};
export default EditorContainer;
here is my working codesandbox: https://codesandbox.io/s/tiptap-editor-state-between-child-and-parent-component-9m0bkh
@mclazer I really like your approach! I've tried replicating it and although I had some success for the most part I'm finding it difficult to update the contents of the editor; I want to change the text editor when necessary.
For example, I initialize useTextEditor
to text in row 0, but when I click on row 5 I want to be able to update the contents of the editor to the text in there. I've tried using custom handler functions and useEffect
but so far nothing is working because useTextEditor
is a hook itself. Were you able to find a work around?
@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
@mclazer its the latter. I solved the problem thanks to you sandbox. I was importing the useTextEditor
wrong and caused React to think the function was a custom hook instead.