Infinite Loop of Re-Renders with React 18's createRoot in Prosemirror React component Integration

I’m trying to render custom React component inside a node. When using React 18’s new createRoot and unmount methods for mounting and unmounting the components, I encounter an infinite loop of re-renders. However, switching back to the deprecated ReactDOM.render and ReactDOM.unmountComponentAtNode methods works as expected without causing re-render issues. I tried to switch to createPortal, but it also causes the same issues. What might be causing the infinite loop of re-renders in this context?

Working Code (With Deprecated Methods):

import { Node } from 'prosemirror-model';
import { Decoration, EditorView, NodeView } from 'prosemirror-view';
import React, { useContext } from 'react';
import { ThemeProvider } from 'styled-components';
import ReactDOM from 'react-dom';
import Theme from '../../../../theme';

interface IReactNodeViewContext {
  node: Node;
  view: EditorView;
  getPos: () => number | undefined;
  decorations: readonly Decoration[];
}

const ReactNodeViewContext = React.createContext<
  Partial<IReactNodeViewContext>
>({
  node: undefined,
  view: undefined,
  getPos: undefined,
  decorations: undefined,
});

class ReactNodeView implements NodeView {
  contentDOM?: HTMLElement | null;
  component: React.FC<any>;
  node: Node;
  view: EditorView;
  getPos: () => number | undefined;
  decorations: readonly Decoration[];
  dom: HTMLElement;

  constructor(
    node: Node,
    view: EditorView,
    getPos: () => number | undefined,
    decorations: readonly Decoration[],
    component: React.FC<any>
  ) {
    this.node = node;
    this.view = view;
    this.getPos = getPos;
    this.decorations = decorations;
    this.component = component;
    this.dom = document.createElement('div');
    this.contentDOM = document.createElement('div');
  }

  init() {
    this.dom.classList.add('claimAdvantage');
    this.render();
    return {
      nodeView: this,
    };
  }
  render = (): void => {
    ReactDOM.render(
      <ThemeProvider theme={Theme}>
        <ReactNodeViewContext.Provider
          value={{
            node: this.node,
            view: this.view,
            getPos: this.getPos,
            decorations: this.decorations,
          }}
        >
          <this.component contentDOMRef={this.handleRef} />
        </ReactNodeViewContext.Provider>
      </ThemeProvider>,
      this.dom
    );
  };
  handleRef = (node: HTMLElement | null): void => {
    if (node && this.contentDOM && !node.contains(this.contentDOM)) {
      node.appendChild(this.contentDOM);
    }
  };

  update(node: Node) {
    if (this.node.type !== node.type) {
      return false;
    }
    this.node = node;
    this.render();
    return true;
  }

  destroy() {
    ReactDOM.unmountComponentAtNode(this.dom);
  }
}

interface TCreateReactNodeView extends IReactNodeViewContext {
  component: React.FC<any>;
}

export const createReactNodeView = ({
  node,
  view,
  getPos,
  decorations,
  component,
}: TCreateReactNodeView) => {
  const reactNodeView = new ReactNodeView(
    node,
    view,
    getPos,
    decorations,
    component
  );
  const { nodeView } = reactNodeView.init();

  return nodeView;
};
export const useReactNodeView = () => useContext(ReactNodeViewContext);
export default ReactNodeView;

Non-Working Code (With React 18 Methods):

import React, { useContext } from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from 'styled-components';
import { Node } from 'prosemirror-model';
import { Decoration, EditorView, NodeView } from 'prosemirror-view';
import Theme from '../../../../theme';

interface IReactNodeViewContext {
  node: Node;
  view: EditorView;
  getPos: () => number | undefined;
  decorations: readonly Decoration[];
}

const ReactNodeViewContext = React.createContext<
  Partial<IReactNodeViewContext>
>({
  node: undefined,
  view: undefined,
  getPos: undefined,
  decorations: undefined,
});

class ReactNodeView implements NodeView {
  contentDOM?: HTMLElement | null;
  component: React.FC<any>;
  node: Node;
  view: EditorView;
  getPos: () => number | undefined;
  decorations: readonly Decoration[];
  dom: HTMLElement;
  root: ReactDOM.Root;

  constructor(
    node: Node,
    view: EditorView,
    getPos: () => number | undefined,
    decorations: readonly Decoration[],
    component: React.FC<any>
  ) {
    this.node = node;
    this.view = view;
    this.getPos = getPos;
    this.decorations = decorations;
    this.component = component;
    this.dom = document.createElement('div');
    this.contentDOM = document.createElement('div');
    this.root = ReactDOM.createRoot(this.dom);
  }

  init() {
    this.dom.classList.add('claimAdvantage');
    this.render();
    return {
      nodeView: this,
    };
  }

  render = (): void => {
    this.root.render(
      <ThemeProvider theme={Theme}>
        <ReactNodeViewContext.Provider
          value={{
            node: this.node,
            view: this.view,
            getPos: this.getPos,
            decorations: this.decorations,
          }}
        >
          <this.component contentDOMRef={this.handleRef} />
        </ReactNodeViewContext.Provider>
      </ThemeProvider>
    );
  };

  handleRef = (node: HTMLElement | null): void => {
    if (node && this.contentDOM && !node.contains(this.contentDOM)) {
      node.appendChild(this.contentDOM);
    }
  };

  update(node: Node) {
    if (this.node.type !== node.type) {
      return false;
    }
    this.node = node;
    this.render();
    return true;
  }

  destroy() {
    this.root.unmount();
  }
}

interface TCreateReactNodeView extends IReactNodeViewContext {
  component: React.FC<any>;
}

export const createReactNodeView = ({
  node,
  view,
  getPos,
  decorations,
  component,
}: TCreateReactNodeView) => {
  const reactNodeView = new ReactNodeView(
    node,
    view,
    getPos,
    decorations,
    component
  );
  const { nodeView } = reactNodeView.init();

  return nodeView;
};

export const useReactNodeView = () => useContext(ReactNodeViewContext);
export default ReactNodeView;

I recommend adding breakpoints to various NodeView methods and checking manually what happens. You might have to add ignoreMutation method:

  ignoreMutation(
    mutation:
      | MutationRecord
      | {
          type: 'selection'
          target: Element
        }
  ): boolean {
    if (mutation.type === 'selection') {
      return false
    } else if (!this.contentDOM) {
      return true
    }
    return !this.contentDOM.contains(mutation.target)
  }

I think Saul-Mirone has kept a set of utility packages updated here prosemirror-adapter/packages/react/src at main · Saul-Mirone/prosemirror-adapter · GitHub

2 Likes

ignoreMutation works) Thanks a lot!!!

1 Like