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:
- A single transaction creates >50 React-backed NodeView instances. (Realistic for paste /
setContent / programmatic dispatch.)
- 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
- Paste / dispatch creates one transaction containing N>50 React-backed NodeView nodes.
EditorView.updateStateInner walks the doc and constructs a ReactNodeView per new node.
- Each
ReactNodeView.mount() instantiates a ReactRenderer, whose constructor calls flushSync(() => this.render()) (ReactRenderer.tsx#L188) when editor.isEditorContentInitialized is true.
render() calls editor.contentComponent.setRenderer(id, this) (ReactRenderer.tsx#L234 → EditorContent.tsx#L65), which synchronously notifies the Portals useSyncExternalStore subscriber.
- 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 mount — EditorContent.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 ReactRenderer → ReactNodeView.mount → NodeViewDesc.create → iterDeco / updateChildren → EditorView.updateStateInner → Editor.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)
Affected versions
@tiptap/react@3.22.3and@3.23.1(latest). React 19, Vite + Vanilla JS or Next.js. All file/line references below are pinned tov3.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.dispatchfrom a React event handler,view.dispatchviasetTimeout, and a syntheticClipboardEventall reproduce identically. Threshold is ~50.Trigger conditions
Two ingredients are both required:
setContent/ programmatic dispatch.)setStatefrom a mount-timeuseEffect— 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 anequationNodeView 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
nestedUpdateCountonly resets when a commit drains all pending work; the editor's churn fromuseSyncExternalStorere-renders never lets that happen.Why
EditorView.updateStateInnerwalks the doc and constructs aReactNodeViewper new node.ReactNodeView.mount()instantiates aReactRenderer, whose constructor callsflushSync(() => this.render())(ReactRenderer.tsx#L188) wheneditor.isEditorContentInitializedis true.render()callseditor.contentComponent.setRenderer(id, this)(ReactRenderer.tsx#L234 → EditorContent.tsx#L65), which synchronously notifies thePortalsuseSyncExternalStoresubscriber.setStatein a mount-timeuseEffect. That state update is "pending work" after the flushSync commit, so React incrementsnestedUpdateCount. After 50 of these in one update cycle,getRootForUpdatedFiberthrows.Notable
Tiptap already protects against this for initial mount — EditorContent.tsx#L123-L125 calls
createNodeViews()before settingisEditorContentInitialized = true, so the initial population takes thequeueMicrotaskbranch inReactRendererand 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
(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 ReactRenderer→ReactNodeView.mount→NodeViewDesc.create→iterDeco/updateChildren→EditorView.updateStateInner→Editor.dispatchTransaction, withdoPasteat the bottom when the trigger is a real paste event.)Workaround
Toggle
editor.isEditorContentInitialized = falsefromeditorProps.handlePaste, restore in aqueueMicrotask. ForcesReactRendererdown itselsebranch (queueMicrotaskinstead offlushSync) so subscriber notifications batch into a singlePortalsre-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.Related (closed)
flushSyncis still present in 3.22.x and 3.23.1flushSyncflushSyncto microtask #3188 — PR that moved part of it toqueueMicrotask(the branch the workaround exploits)