tinymce-react icon indicating copy to clipboard operation
tinymce-react copied to clipboard

onEditorChange triggers on first render

Open kostas-pblworks opened this issue 10 months ago • 15 comments

What is the current behavior?

The onEditorChange callback fires immidiatelly on the first render. I'm not sure if thats expected but I would expect to not fire since there was no actual change to the content.

Same happens with either the value or initialValue property.

Please provide the steps to reproduce and if possible a minimal demo of the problem via codesandbox.io or similar.

https://codesandbox.io/p/devbox/vibrant-wilson-ms4v5p

What is the expected behavior?

onEditorChange should not fire

Which versions of TinyMCE, and which browser / OS are affected by this issue? Did this work in previous versions of TinyMCE or tinymce-react?

version 4.3.2

kostas-pblworks avatar Apr 14 '25 07:04 kostas-pblworks

I think this might be related to https://github.com/tinymce/tinymce-react/issues/298 In a nutshell: you have to provide the exact markup that tinyMCE expects. Otherwise it will normalize to markup, fire a change event and gets dirty. And there is no possibility to detect weather that was triggered by a user input or by an automatic normalization. That's a huge, very critical issue when using the editor with forms that leaves the controlled editor almost useless. Still fighting that bug on almost every release.

Mario-Eis avatar Apr 25 '25 12:04 Mario-Eis

@Mario-Eis Could you elaborate why this is so critical? Is it not possible for you to make sure you only pass normalized HTML into the value/initialValue?

carlosmintfan avatar May 05 '25 17:05 carlosmintfan

Like by getting the normalized value from TinyMCE manually and only passing that in the future?

carlosmintfan avatar May 05 '25 17:05 carlosmintfan

@carlosmintfan how about the first time you want to pass a default value and it hasn't be "normalized" yet?

kostas-pblworks avatar May 06 '25 06:05 kostas-pblworks

Well, can’t you do that in some testing project where it doesn’t matter if the change event fires just to get the normalized value? I‘d like to get a deeper understanding of the problem!

carlosmintfan avatar May 06 '25 12:05 carlosmintfan

Is your initial value not hardcoded into your application but fetched dynamically from some database? Yes, then it gets more complicated if it doesn’t originate from TinyMCE output

carlosmintfan avatar May 06 '25 12:05 carlosmintfan

@Mario-Eis Could you elaborate why this is so critical? Is it not possible for you to make sure you only pass normalized HTML into the value/initialValue?

@carlosmintfan Exactly. In our case, we fetch the data from a database and use tinymce-react in a form. react-hook-form in our case. TinyMCE is one input field of many. And permanently sets the form to dirty when it is not. In some cases the data in that field is created by sources that are not tinymce. But the HTML is still perfectly valid and renders perfectly fine. So normalizing it when the first user input is made and the form legally gets dirty would be the desired behavior. Instead of setting the form to dirty automatically without user input.

Mario-Eis avatar Jul 24 '25 16:07 Mario-Eis

Same issue here.

szado avatar Sep 26 '25 06:09 szado

PLEEEEEEEEEEEEEASE!!! Fix this!!! I will lose my job!!!

Dionnie avatar Oct 04 '25 18:10 Dionnie

@Mario-Eis @szado @Dionnie Digging into this now (although I am not a Tiny employee); are you giving the initial value from the form lib to the editor synchronously or asynchronously? Because if you're doing it synchronously there should be a workaround: https://github.com/tinymce/tinymce-react/issues/298#issuecomment-1181299512 The problem (for which I may have another workaround, I'll have to test) is setting the initial content asynchronously: https://github.com/tinymce/tinymce-react/issues/298#issuecomment-1182845919

carlosmintfan avatar Oct 04 '25 19:10 carlosmintfan

import { useRef } from 'react';

function EditorWrapper(props) {
  const hasInitialized = useRef(false);

  const handleEditorChange = (content, editor) => {
    if (!hasInitialized.current) {
      // Skip the first call
      hasInitialized.current = true;
      return;
    }
    // Only call the user's callback on subsequent changes
    props.onEditorChange?.(content, editor);
  };

  return (
    <Editor
      {...props}
      onEditorChange={handleEditorChange}
    />
  );
}

I got this code from GitHub copilot – do you think it'd work for you? You'd have to make sure, however, that the initial value (even if you don't pass it using the initialValue prop) is always different to its normalized version, otherwise you'd probably end up with the opposite problem: showing the form not being dirty although the user has already made an edit. As a simple workaround you could just add some trailing or leading spaces to the initial value, either client or server side. Do you think this would work?

carlosmintfan avatar Oct 04 '25 19:10 carlosmintfan

I only tried to stop tinymce from "misfiring". But forcing it to do so and irgnore it every time was not on my radar. Manipulating the content with whitespaces before loading it into the form could theoretically work. That means IF tinymce removes whitespaces on normalized content.

What we do for the time being:

    if (currentContent !== previousCurrentContentRef.current.current) {
        previousCurrentContentRef.current = {current: currentContent, previous: previousCurrentContentRef.current.current};
    }

    useEffect(() => {
        if (normalizedValue !== previousNormalizedValue) {
            // value was changed from outside
            setPreviousNormalizedValue(normalizedValue);
            setInitialValue(normalizedValue);
            setCurrentContent(normalizedValue);
            setInitialContent(null);
            editorRef.current?.resetContent(normalizedValue); // re-trigger the initial normalization
        }
    }, [initialValue, normalizedValue, previousNormalizedValue]);

    useEffect(() => {
        // the only possible way to detect if a change event was triggered by an initial automatic normalization is with the addUndo event: See https://github.com/tinymce/tinymce-react/issues/298
        // unfortunately the addUndo event triggers after the editor change event. So we can not fire onChange there. That's why onChange is fired with useEffect.
        if (onChange == null || currentContent === previousCurrentContentRef.current.previous || initialContent == null) {
            return;
        }

        const outputValue = currentContent !== initialContent ? currentContent : initialValue;
        onChange(outputValue === "" ? null : outputValue);
        setPreviousNormalizedValue(outputValue);
    }, [onChange, currentContent, initialContent, initialValue]);

    const handleEditorChange = useCallback<FieldType<IAllProps, "onEditorChange">>(newValue => {
        // unfortunately the addUndo event is the only place where we can detect initial automatic normalizations. But it triggers after the editor change event. So we can not fire onChange here.
        setCurrentContent(newValue);
    }, []);

    const handleAddUndo = useCallback<FieldType<IAllProps, "onAddUndo">>((evt, editor) => {
        if (evt.lastLevel === undefined) {
            // This stores the initial (lastLevel === undefined), normalized content in a state. The state is then used to determine if there were editor changes (editor is dirty).
            // This is needed to prevent issues with markup that was not created directly by tinymce (would look the same, but is different on markup level and would therefore result in a dirty state)
            const newCurrentContent = editor.getContent();
            setInitialContent(newCurrentContent);
            setCurrentContent(newCurrentContent);
            previousCurrentContentRef.current = {current: newCurrentContent, previous: newCurrentContent};
        }
    }, []);

Basically the idea is to monitor addUndo to get the initial state from there when the undo stack is empty. editorChange is also monitored to get the changed values. Now the problem is, that editorChange fires before addUndo. So we need to track both values in states and compare them in a useEffect to determine wether it was a legitimate user input or an automatic normalization. And another useEffect to reset the whole logic when the props changed (controlled behaviour).

It works more or less. But unfortunately that is some really serious logic that is hard to follow for new develpers. And we do have no idea for how long that would work and if it works perfect at all. Because the code was developed empirically and uses not offically documented behaviour.

Mario-Eis avatar Oct 05 '25 08:10 Mario-Eis

Glad you already found a workaround! But yes, it‘s quite complicated and probably does take some time to understand. TinyMCE does remove trailing and leading whitespaces, I tested it, so migrating to the workaround I shared could be a simplification although…it‘s hacky in a different way :) Anywqy, an important thing to take care of is: If TinyMCE will incorporate the workaround I shared into the codebase of tinymce-react, then this MUST be done in a major version because it is a breaking change for everyone who applied it manually: If both tinymce-react and your code ignore the first event they get, then the first real change event also gets ignored, so then it would be very important to remove the workaround from your codebase before upgrading.

carlosmintfan avatar Oct 05 '25 13:10 carlosmintfan

Note: If a user makes a change and then reverts it, the editor will still be considered dirty if you use my workaround. I'm not sure if your workaround is more stable in this regard.

carlosmintfan avatar Oct 07 '25 16:10 carlosmintfan

Actually i dont really know if my workaround handles the dirty state of tinymce. I would need to have a look. But it is not that important. Because the form manages its own dirty state. When the form resets and tinymce does not fire an onChange imediatly (what the workaround manages to mitigate), the form stays "not dirty". I think by ignoring the first onChange in your workaround, it could also manage to keep the correct state in the form.

Mario-Eis avatar Oct 16 '25 08:10 Mario-Eis