nytimes / react-prosemirror

A library for safely integrating ProseMirror and React.
Other
459 stars 17 forks source link

useEditorState doesn't update after EditorView.updateState #98

Closed BrianHung closed 8 months ago

BrianHung commented 9 months ago

I have a React component that on-mount, creates a ProseMirror plugin and reconfigures the view state to include it. (This paradigm of React component registering plugins themselves makes sense when editor ui is conditional but still complicated enough to need access to state and view plugins).

export const EditorWordCount = () => {
    const state = useEditorState();

    useEditorEffect(view => {
        if (view === null) return;
        const state = view.state;
        view.updateState(
            state.reconfigure({
                plugins: state.plugins.concat([WordCount()]),
            })
        );
        return () => {
            const state = view.state;
            const plugin = WordCountKey.get(state);
            view.updateState(
                state.reconfigure({
                    plugins: state.plugins.filter(p => p !== plugin),
                })
            );
        };
    }, []);

    const count = state && WordCountKey.getState(state);
    if (!count) return;
    return (
        <div className="space-x-2 px-4 py-2 text-right text-xs tabular-nums text-gray-400">
            <span>Characters: {count.characters}</span>
            <span>Words: {count.words}</span>
            <span>Sentences: {count.sentences}</span>
        </div>
    );
};

The issue is that useEditorState doesn't update after the view.updateState call.

I checked the source code of this library and there doesn't seem to be any code that calls setState after view.updateState or view.setProps({ state }) is called.

WordCount Plugin source code ```ts import { Plugin, PluginKey } from 'prosemirror-state'; export const WordCountKey = new PluginKey('WordCount'); export function WordCount() { /** * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter */ const grapheme = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); const word = new Intl.Segmenter(undefined, { granularity: 'word' }); const sentence = new Intl.Segmenter(undefined, { granularity: 'sentence' }); const segment = (text: string) => ({ characters: [...grapheme.segment(text)].length, words: [...word.segment(text)].filter(segment => segment.isWordLike).length, sentences: [...sentence.segment(text)].length, }); return new Plugin({ key: WordCountKey, state: { init(config, state) { let textContent = state.doc.textBetween(0, state.doc.content.size, ' ', ' '); return segment(textContent); }, apply(tr, pluginState, prevState, state) { if (!tr.docChanged) return pluginState; let textContent = state.doc.textBetween(0, state.doc.content.size, ' ', ' '); return segment(textContent); }, }, }); } export default WordCount; ```
tilgovi commented 8 months ago

It's not safe to update the state of the view that way. We haven't made any attempt to intercept calls to updateState() or setProps() or anything like that. If you want to control the state, you need to pass state into the <ProseMirror /> component and if you want to update that state you should update it using normal React idioms.

BrianHung commented 8 months ago

After reading the prosemirror-view source code, there's not a direct way to listen to state changes made with setProps or updateState on the EditorView; dispatchTransaction works only for transactions, and creating or deleting plugins doesn't fall into that.

There is perhaps a plugin way

class StateListenerView {
  constructor(readonly view: EditorView, readonly listeners: Set<EditorListener>) {
    listeners.forEach(l => l(view, undefined));
  }
  update(view, prevState) {
    this.listeners.forEach(l => l(view, prevState));
  }
  destroy() {
   const view = this.view;
   this.listeners.forEach(l => l(view, undefined));
  }
}

const ReactViewSync = new Plugin({
   view: view => new StateListenerView(view, listeners),
});

// setState could be a listener if editorProps.setState existed
function maybeSetState(view, prevState) {
  if (view.state !== state) setState(view.state) // view.setProps or view.updateState was called instead of setState
}

but then it's no longer a uni-directional data flow where React is always the source of truth.

This would be the React idiomatic way instead.

export const EditorWordCount = ({ state, setState }) => {       
    useEffect(function initPlugin() {
        const plugin = WordCount()
        setState(state =>
            state.reconfigure({
                plugins: state.plugins.concat([plugin]),
            })
        );
        return () => {
            setState(state =>
                state.reconfigure({
                    plugins: state.plugins.filter(p => p !== plugin),
                })
            );
        }
    }, []);

    const count = state && WordCountKey.getState(state);
    if (!count) return;
    return (
        <div className="space-x-2 px-4 py-2 text-right text-xs tabular-nums text-gray-400">
            <span>Characters: {count.characters}</span>
            <span>Words: {count.words}</span>
            <span>Sentences: {count.sentences}</span>
        </div>
    );
};
tilgovi commented 8 months ago

That's a very cool example! I haven't seen any dynamic registration of state plugins this way. We have similar components at @nytimes that read from plugin state, but we generally just configure the plugin when we create the editor state. It's neat the way you've made this self-contained.

It might make sense to make some kind of API in react-prosemirror for registering stateful plugins like this. If you find yourself repeating this pattern a lot, hop into the Gitter room and talk with us about maybe putting something into the library to support it, please!