Skip to content

[Bug]: Maximum update depth exceeded permanently breaks editor after large React-NodeView dispatch #7811

@szilard-dobai

Description

@szilard-dobai

Affected versions

@tiptap/react@3.22.3 and @3.23.1 (latest). React 19, Vite + Vanilla JS or Next.js. All file/line references below are pinned to v3.23.1.

Repro

https://github.com/szilard-dobai/tiptap-flushsync-repro
CodeSandbox: https://codesandbox.io/s/github/szilard-dobai/tiptap-flushsync-repro

Three buttons demonstrate that the trigger doesn't matter — view.dispatch from a React event handler, view.dispatch via setTimeout, and a synthetic ClipboardEvent all reproduce identically. Threshold is ~50.

Trigger conditions

Two ingredients are both required:

  1. A single transaction creates >50 React-backed NodeView instances. (Realistic for paste / setContent / programmatic dispatch.)
  2. The NodeView's React component fires setState from a mount-time useEffect — i.e. anything that lazy-initializes a library, measures the DOM, hydrates state from a ref, etc. Trivial NodeViews that render purely from props don't reproduce, but most real-world NodeViews do. (We originally hit this with an equation NodeView that lazy-loads a MathML editor.)

Persistence

Once a single dispatch trips the limit, every subsequent dispatch throws — even one inserting only ~40 atoms. Page reload required. React's nestedUpdateCount only resets when a commit drains all pending work; the editor's churn from useSyncExternalStore re-renders never lets that happen.

Why

  1. Paste / dispatch creates one transaction containing N>50 React-backed NodeView nodes.
  2. EditorView.updateStateInner walks the doc and constructs a ReactNodeView per new node.
  3. Each ReactNodeView.mount() instantiates a ReactRenderer, whose constructor calls flushSync(() => this.render()) (ReactRenderer.tsx#L188) when editor.isEditorContentInitialized is true.
  4. render() calls editor.contentComponent.setRenderer(id, this) (ReactRenderer.tsx#L234EditorContent.tsx#L65), which synchronously notifies the Portals useSyncExternalStore subscriber.
  5. Each NodeView's React component fires setState in a mount-time useEffect. That state update is "pending work" after the flushSync commit, so React increments nestedUpdateCount. After 50 of these in one update cycle, getRootForUpdatedFiber throws.

Notable

Tiptap already protects against this for initial mountEditorContent.tsx#L123-L125 calls createNodeViews() before setting isEditorContentInitialized = true, so the initial population takes the queueMicrotask branch in ReactRenderer and never trips the limit even with hundreds of NodeViews. The same protection just isn't available for bulk dispatches that happen later (paste, setContent, programmatic insert).

Stack trace from the CodeSandbox repro

at getRootForUpdatedFiber              (react-dom)
at enqueueConcurrentRenderForLane      (react-dom)
at forceStoreRerender                  (useSyncExternalStore subscriber)
at Set.forEach (subscribers)
at Object.setRenderer                  (EditorContent.tsx, line 65)
at ReactRenderer.render                (ReactRenderer.tsx, line 234)
at flushSync                           (ReactRenderer.tsx, line 188)

(Vite minifies/hoists the deeper frames above flushSync. In a Next.js + React 19 dev build the same throw shows the rest of the chain: new ReactRendererReactNodeView.mountNodeViewDesc.createiterDeco / updateChildrenEditorView.updateStateInnerEditor.dispatchTransaction, with doPaste at the bottom when the trigger is a real paste event.)

Workaround

Toggle editor.isEditorContentInitialized = false from editorProps.handlePaste, restore in a queueMicrotask. Forces ReactRenderer down its else branch (queueMicrotask instead of flushSync) so subscriber notifications batch into a single Portals re-render. The flag is internal — a public API for "render NodeViews asynchronously during this transaction" would be appreciated, since the alternative is reaching into private state.

  const editor = useEditor({
    // ...
    editorProps: {
      handlePaste: (view) => {
        const editor: ReactRenderer["editor"] | undefined = (
          view.dom as TiptapEditorHTMLElement
        ).editor;
        if (!editor) return false;
        const wasInitialized = editor.isEditorContentInitialized;
        editor.isEditorContentInitialized = false;
        queueMicrotask(() => {
          editor.isEditorContentInitialized = wasInitialized;
        });
        return false;
      },
    },
  });

Related (closed)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions