From 1f9ca89f43d3d06da17fb218e0c31c09b5c1a1bd Mon Sep 17 00:00:00 2001 From: a-y-ibrahim Date: Fri, 17 Jul 2026 03:41:39 +0300 Subject: [PATCH 1/7] fix(react): prevent onCreate firing twice for StrictMode duplicate React 18/19 StrictMode invokes useEditor's internal useState lazy initializer twice for a single logical mount, keeping only the first call's result. Both calls construct (and auto-mount, since Editor defaults to a detached div) a real Editor, so both schedule their own onCreate via a 0ms timer - the existing scheduleDestroy cleanup runs 1ms later, always losing that race. Track, per useEditor() call, the most recent EditorInstanceManager still unconfirmed as mounted. A duplicate construction now destroys itself synchronously, before its Editor's onCreate has a chance to fire, instead of relying on the slower timer-based race. Fixes #7091 --- .../fix-react-strictmode-double-oncreate.md | 5 + packages/react/src/useEditor.spec.ts | 231 ++++++++++++++++++ packages/react/src/useEditor.ts | 43 +++- 3 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-react-strictmode-double-oncreate.md create mode 100644 packages/react/src/useEditor.spec.ts diff --git a/.changeset/fix-react-strictmode-double-oncreate.md b/.changeset/fix-react-strictmode-double-oncreate.md new file mode 100644 index 0000000000..623b2d4c70 --- /dev/null +++ b/.changeset/fix-react-strictmode-double-oncreate.md @@ -0,0 +1,5 @@ +--- +'@tiptap/react': patch +--- + +Fix `useEditor` firing `onCreate` twice for two different `Editor` instances under React StrictMode. StrictMode invokes the hook's internal lazy initializer twice, and both calls used to construct and auto-mount a real `Editor`; the discarded instance is now destroyed synchronously, before its `create` event has a chance to fire. diff --git a/packages/react/src/useEditor.spec.ts b/packages/react/src/useEditor.spec.ts new file mode 100644 index 0000000000..3975b80335 --- /dev/null +++ b/packages/react/src/useEditor.spec.ts @@ -0,0 +1,231 @@ +import { Document } from '@tiptap/extension-document' +import { Paragraph } from '@tiptap/extension-paragraph' +import { Text } from '@tiptap/extension-text' +import { render } from '@testing-library/react' +import React from 'react' +import { afterEach, describe, expect, it } from 'vitest' + +import type { Editor } from '@tiptap/core' +import { useEditor } from './useEditor.js' + +function flushTimers(ms: number) { + return new Promise(resolve => { + setTimeout(resolve, ms) + }) +} + +describe('useEditor', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + it('does not fire onCreate more than once for a single mount under StrictMode', async () => { + let createCount = 0 + const createdEditors = new Set() + let latestEditor: Editor | null = null + + function TestComponent() { + const editor = useEditor({ + extensions: [Document, Text, Paragraph], + onCreate: ({ editor: createdEditor }) => { + createCount += 1 + createdEditors.add(createdEditor) + }, + }) + + latestEditor = editor + + return null + } + + const { unmount } = render( + React.createElement(React.StrictMode, null, React.createElement(TestComponent)), + ) + + // The editor's own 'create' event fires via an internal setTimeout(0); + // give it real wall-clock time to flush. + await flushTimers(100) + + expect(createCount).toBe(1) + expect(createdEditors.size).toBe(1) + expect(latestEditor?.isDestroyed).toBe(false) + + unmount() + }) + + it('keeps two sibling editors independent under StrictMode', async () => { + let createCountA = 0 + let createCountB = 0 + const createdA = new Set() + const createdB = new Set() + let editorA: Editor | null = null + let editorB: Editor | null = null + + function ComponentA() { + const editor = useEditor({ + extensions: [Document, Text, Paragraph], + onCreate: ({ editor: e }) => { + createCountA += 1 + createdA.add(e) + }, + }) + + editorA = editor + + return null + } + + function ComponentB() { + const editor = useEditor({ + extensions: [Document, Text, Paragraph], + onCreate: ({ editor: e }) => { + createCountB += 1 + createdB.add(e) + }, + }) + + editorB = editor + + return null + } + + const { unmount } = render( + React.createElement( + React.StrictMode, + null, + React.createElement(ComponentA), + React.createElement(ComponentB), + ), + ) + + await flushTimers(100) + + expect(createCountA).toBe(1) + expect(createCountB).toBe(1) + expect(createdA.size).toBe(1) + expect(createdB.size).toBe(1) + expect(editorA?.isDestroyed).toBe(false) + expect(editorB?.isDestroyed).toBe(false) + expect(editorA).not.toBe(editorB) + + unmount() + }) + + it('does not throw and still creates exactly one editor when immediatelyRender is false under StrictMode', async () => { + let createCount = 0 + + function TestComponent() { + useEditor({ + immediatelyRender: false, + extensions: [Document, Text, Paragraph], + onCreate: () => { + createCount += 1 + }, + }) + + return null + } + + let unmount: () => void = () => {} + + expect(() => { + ;({ unmount } = render( + React.createElement(React.StrictMode, null, React.createElement(TestComponent)), + )) + }).not.toThrow() + + await flushTimers(100) + + expect(createCount).toBe(1) + + unmount() + }) + + it('handles a full unmount and remount cycle correctly under StrictMode', async () => { + // Note: a StrictMode mount's discarded duplicate instance is destroyed + // (and so fires its own onDestroy) regardless of this fix - that already + // happened before this fix too, just 1ms later instead of synchronously. + // This test tracks *deltas* around the real show/hide toggle so that + // pre-existing, orthogonal onDestroy noise from StrictMode's duplicate + // doesn't get mistaken for a real unmount. + let createCount = 0 + let destroyCount = 0 + + function TestComponent() { + useEditor({ + extensions: [Document, Text, Paragraph], + onCreate: () => { + createCount += 1 + }, + onDestroy: () => { + destroyCount += 1 + }, + }) + + return null + } + + function Wrapper({ show }: { show: boolean }) { + return show ? React.createElement(TestComponent) : null + } + + const { rerender, unmount } = render( + React.createElement(React.StrictMode, null, React.createElement(Wrapper, { show: true })), + ) + + await flushTimers(100) + expect(createCount).toBe(1) + + const destroyCountBeforeHiding = destroyCount + + rerender( + React.createElement(React.StrictMode, null, React.createElement(Wrapper, { show: false })), + ) + await flushTimers(100) + expect(destroyCount - destroyCountBeforeHiding).toBe(1) + + rerender( + React.createElement(React.StrictMode, null, React.createElement(Wrapper, { show: true })), + ) + await flushTimers(100) + expect(createCount).toBe(2) + + unmount() + }) + + it('does not crash the render if a user callback throws while evicting a discarded StrictMode duplicate', async () => { + function TestComponent() { + useEditor({ + extensions: [Document, Text, Paragraph], + onDestroy: () => { + throw new Error('boom from onDestroy') + }, + }) + + return null + } + + let caughtAsync: unknown = null + const onUnhandled = (error: unknown) => { + caughtAsync = error + } + + process.on('uncaughtException', onUnhandled) + + try { + expect(() => { + render(React.createElement(React.StrictMode, null, React.createElement(TestComponent))) + }).not.toThrow() + + await flushTimers(100) + } finally { + process.off('uncaughtException', onUnhandled) + } + + // The throw is still surfaced (matching pre-existing behavior for a + // throwing onDestroy) - it just must not happen synchronously inside + // React's render phase. + expect(caughtAsync).toBeInstanceOf(Error) + expect((caughtAsync as Error).message).toBe('boom from onDestroy') + }) +}) diff --git a/packages/react/src/useEditor.ts b/packages/react/src/useEditor.ts index 2a13a54cf1..126a63d04f 100644 --- a/packages/react/src/useEditor.ts +++ b/packages/react/src/useEditor.ts @@ -29,6 +29,20 @@ export type UseEditorOptions = Partial & { shouldRerenderOnTransaction?: boolean } +/** + * React 18/19 StrictMode invokes a `useState` lazy initializer twice for a + * single logical mount, keeping only the first call's result - but both + * calls synchronously construct (and auto-mount) a real `Editor`. Tracks, + * per `useEditor()` call (keyed by its stable options ref), the most recent + * `EditorInstanceManager` still waiting to be confirmed as mounted, so a + * duplicate construction can destroy itself before its `Editor`'s `onCreate` + * (scheduled via a 0ms timer) fires for an instance nobody will use. + */ +const unconfirmedInstanceManagers = new WeakMap< + MutableRefObject, + EditorInstanceManager +>() + /** * This class handles the creation, destruction, and re-creation of the editor instance. */ @@ -73,7 +87,31 @@ class EditorInstanceManager { this.options = options this.subscriptions = new Set<() => void>() this.setEditor(this.getInitialEditor()) - this.scheduleDestroy() + + const unconfirmedInstance = unconfirmedInstanceManagers.get(options) + + if (unconfirmedInstance && !unconfirmedInstance.isComponentMounted) { + // We're a duplicate construction from StrictMode's double-invoked lazy + // initializer. React keeps the first call's result, so this instance + // is guaranteed to be discarded - destroy it synchronously, before + // its Editor's 0ms-scheduled 'create' emission has a chance to fire. + try { + this.editor?.destroy() + } catch (error) { + // A user callback (e.g. onDestroy) threw. Don't let that propagate + // through React's render phase - surface it asynchronously instead, + // same as it would have surfaced before this instance was destroyed + // synchronously (e.g. via the scheduleDestroy timer below). + setTimeout(() => { + throw error + }) + } finally { + this.editor = null + } + } else { + unconfirmedInstanceManagers.set(options, this) + this.scheduleDestroy() + } this.getEditor = this.getEditor.bind(this) this.getServerSnapshot = this.getServerSnapshot.bind(this) @@ -219,6 +257,9 @@ class EditorInstanceManager { // The returned callback will run on each render return () => { this.isComponentMounted = true + // We've been confirmed as the real instance for this hook call - no + // longer relevant to the StrictMode-duplicate detection above. + unconfirmedInstanceManagers.delete(this.options) // Cleanup any scheduled destructions, since we are currently rendering clearTimeout(this.scheduledDestructionTimeout) From beef6fb43cb00ff6a8bf52ddc615f62ab2cde256 Mon Sep 17 00:00:00 2001 From: a-y-ibrahim Date: Fri, 17 Jul 2026 04:41:30 +0300 Subject: [PATCH 2/7] fix(react): simplify StrictMode duplicate fix to a guarded useRef The previous commit let both StrictMode-duplicated EditorInstanceManager constructions happen and then synchronously evicted the discarded one. That still let onBeforeCreate/onMount double-fire (unavoidable once a second Editor is constructed at all), and needed extra handling for a user callback throwing during the synchronous eviction. Replace the useState lazy initializer with a useRef guard instead: a ref's `current` is not reset between StrictMode's double invocation of the component function, so checking `if (ref.current === null)` before constructing prevents the second Editor from ever being constructed in the first place. This is simpler, removes the need for the eviction machinery entirely, and also fixes the onBeforeCreate/onMount double-fire as a side effect. --- .../fix-react-strictmode-double-oncreate.md | 2 +- packages/react/src/useEditor.spec.ts | 52 +++------------- packages/react/src/useEditor.ts | 59 +++++-------------- 3 files changed, 25 insertions(+), 88 deletions(-) diff --git a/.changeset/fix-react-strictmode-double-oncreate.md b/.changeset/fix-react-strictmode-double-oncreate.md index 623b2d4c70..1e24bbea77 100644 --- a/.changeset/fix-react-strictmode-double-oncreate.md +++ b/.changeset/fix-react-strictmode-double-oncreate.md @@ -2,4 +2,4 @@ '@tiptap/react': patch --- -Fix `useEditor` firing `onCreate` twice for two different `Editor` instances under React StrictMode. StrictMode invokes the hook's internal lazy initializer twice, and both calls used to construct and auto-mount a real `Editor`; the discarded instance is now destroyed synchronously, before its `create` event has a chance to fire. +Fix `useEditor` firing `onBeforeCreate`/`onCreate` twice for two different `Editor` instances under React StrictMode. `useEditor` used a `useState` lazy initializer, which StrictMode invokes twice for a single mount, and both calls constructed (and auto-mounted) a real `Editor`. Replaced it with a guarded `useRef`, whose value isn't reset between StrictMode's double invocation, so only one `Editor` is ever constructed per mount. diff --git a/packages/react/src/useEditor.spec.ts b/packages/react/src/useEditor.spec.ts index 3975b80335..997928ed3d 100644 --- a/packages/react/src/useEditor.spec.ts +++ b/packages/react/src/useEditor.spec.ts @@ -19,7 +19,8 @@ describe('useEditor', () => { document.body.innerHTML = '' }) - it('does not fire onCreate more than once for a single mount under StrictMode', async () => { + it('does not fire onBeforeCreate/onCreate more than once for a single mount under StrictMode', async () => { + let beforeCreateCount = 0 let createCount = 0 const createdEditors = new Set() let latestEditor: Editor | null = null @@ -27,6 +28,9 @@ describe('useEditor', () => { function TestComponent() { const editor = useEditor({ extensions: [Document, Text, Paragraph], + onBeforeCreate: () => { + beforeCreateCount += 1 + }, onCreate: ({ editor: createdEditor }) => { createCount += 1 createdEditors.add(createdEditor) @@ -46,6 +50,7 @@ describe('useEditor', () => { // give it real wall-clock time to flush. await flushTimers(100) + expect(beforeCreateCount).toBe(1) expect(createCount).toBe(1) expect(createdEditors.size).toBe(1) expect(latestEditor?.isDestroyed).toBe(false) @@ -142,12 +147,9 @@ describe('useEditor', () => { }) it('handles a full unmount and remount cycle correctly under StrictMode', async () => { - // Note: a StrictMode mount's discarded duplicate instance is destroyed - // (and so fires its own onDestroy) regardless of this fix - that already - // happened before this fix too, just 1ms later instead of synchronously. - // This test tracks *deltas* around the real show/hide toggle so that - // pre-existing, orthogonal onDestroy noise from StrictMode's duplicate - // doesn't get mistaken for a real unmount. + // Tracks *deltas* around the real show/hide toggle, rather than an + // absolute destroyCount, so this stays correct regardless of exactly + // when the guard's ref is (re-)initialized across mounts. let createCount = 0 let destroyCount = 0 @@ -192,40 +194,4 @@ describe('useEditor', () => { unmount() }) - - it('does not crash the render if a user callback throws while evicting a discarded StrictMode duplicate', async () => { - function TestComponent() { - useEditor({ - extensions: [Document, Text, Paragraph], - onDestroy: () => { - throw new Error('boom from onDestroy') - }, - }) - - return null - } - - let caughtAsync: unknown = null - const onUnhandled = (error: unknown) => { - caughtAsync = error - } - - process.on('uncaughtException', onUnhandled) - - try { - expect(() => { - render(React.createElement(React.StrictMode, null, React.createElement(TestComponent))) - }).not.toThrow() - - await flushTimers(100) - } finally { - process.off('uncaughtException', onUnhandled) - } - - // The throw is still surfaced (matching pre-existing behavior for a - // throwing onDestroy) - it just must not happen synchronously inside - // React's render phase. - expect(caughtAsync).toBeInstanceOf(Error) - expect((caughtAsync as Error).message).toBe('boom from onDestroy') - }) }) diff --git a/packages/react/src/useEditor.ts b/packages/react/src/useEditor.ts index 126a63d04f..a3cb9a6dd9 100644 --- a/packages/react/src/useEditor.ts +++ b/packages/react/src/useEditor.ts @@ -1,6 +1,6 @@ import { type EditorOptions, Editor } from '@tiptap/core' import type { DependencyList, MutableRefObject } from 'react' -import { useDebugValue, useEffect, useRef, useState } from 'react' +import { useDebugValue, useEffect, useRef } from 'react' import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js' import { useEditorState } from './useEditorState.js' @@ -29,20 +29,6 @@ export type UseEditorOptions = Partial & { shouldRerenderOnTransaction?: boolean } -/** - * React 18/19 StrictMode invokes a `useState` lazy initializer twice for a - * single logical mount, keeping only the first call's result - but both - * calls synchronously construct (and auto-mount) a real `Editor`. Tracks, - * per `useEditor()` call (keyed by its stable options ref), the most recent - * `EditorInstanceManager` still waiting to be confirmed as mounted, so a - * duplicate construction can destroy itself before its `Editor`'s `onCreate` - * (scheduled via a 0ms timer) fires for an instance nobody will use. - */ -const unconfirmedInstanceManagers = new WeakMap< - MutableRefObject, - EditorInstanceManager ->() - /** * This class handles the creation, destruction, and re-creation of the editor instance. */ @@ -87,31 +73,7 @@ class EditorInstanceManager { this.options = options this.subscriptions = new Set<() => void>() this.setEditor(this.getInitialEditor()) - - const unconfirmedInstance = unconfirmedInstanceManagers.get(options) - - if (unconfirmedInstance && !unconfirmedInstance.isComponentMounted) { - // We're a duplicate construction from StrictMode's double-invoked lazy - // initializer. React keeps the first call's result, so this instance - // is guaranteed to be discarded - destroy it synchronously, before - // its Editor's 0ms-scheduled 'create' emission has a chance to fire. - try { - this.editor?.destroy() - } catch (error) { - // A user callback (e.g. onDestroy) threw. Don't let that propagate - // through React's render phase - surface it asynchronously instead, - // same as it would have surfaced before this instance was destroyed - // synchronously (e.g. via the scheduleDestroy timer below). - setTimeout(() => { - throw error - }) - } finally { - this.editor = null - } - } else { - unconfirmedInstanceManagers.set(options, this) - this.scheduleDestroy() - } + this.scheduleDestroy() this.getEditor = this.getEditor.bind(this) this.getServerSnapshot = this.getServerSnapshot.bind(this) @@ -257,9 +219,6 @@ class EditorInstanceManager { // The returned callback will run on each render return () => { this.isComponentMounted = true - // We've been confirmed as the real instance for this hook call - no - // longer relevant to the StrictMode-duplicate detection above. - unconfirmedInstanceManagers.delete(this.options) // Cleanup any scheduled destructions, since we are currently rendering clearTimeout(this.scheduledDestructionTimeout) @@ -381,7 +340,19 @@ export function useEditor( mostRecentOptions.current = options - const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions)) + // Deliberately not a `useState` lazy initializer: React 18/19 StrictMode + // invokes that twice for a single logical mount, and since it constructs + // (and auto-mounts) a real `Editor`, that fires `onCreate` twice for two + // different instances. A ref's `current` is not reset between StrictMode's + // double invocation of this component function, so this guard only ever + // constructs one `EditorInstanceManager` per mount. + const instanceManagerRef = useRef(null) + + if (instanceManagerRef.current === null) { + instanceManagerRef.current = new EditorInstanceManager(mostRecentOptions) + } + + const instanceManager = instanceManagerRef.current const editor = useSyncExternalStore( instanceManager.subscribe, From 5227c5562b4a16f43c832e7387c2864fd65df762 Mon Sep 17 00:00:00 2001 From: a-y-ibrahim Date: Fri, 17 Jul 2026 04:48:44 +0300 Subject: [PATCH 3/7] test(react): add explicit non-StrictMode regression check for useEditor All existing StrictMode-focused tests wrapped everything in React.StrictMode; add a plain-render counterpart so a future change can't silently break normal (non-StrictMode) mounting. --- packages/react/src/useEditor.spec.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/react/src/useEditor.spec.ts b/packages/react/src/useEditor.spec.ts index 997928ed3d..b0847745b9 100644 --- a/packages/react/src/useEditor.spec.ts +++ b/packages/react/src/useEditor.spec.ts @@ -58,6 +58,29 @@ describe('useEditor', () => { unmount() }) + it('still fires onCreate exactly once when mounted without StrictMode', async () => { + let createCount = 0 + + function TestComponent() { + useEditor({ + extensions: [Document, Text, Paragraph], + onCreate: () => { + createCount += 1 + }, + }) + + return null + } + + const { unmount } = render(React.createElement(TestComponent)) + + await flushTimers(100) + + expect(createCount).toBe(1) + + unmount() + }) + it('keeps two sibling editors independent under StrictMode', async () => { let createCountA = 0 let createCountB = 0 From c08706d90156642ebe39bffc3470dc690fa97127 Mon Sep 17 00:00:00 2001 From: a-y-ibrahim Date: Fri, 17 Jul 2026 05:12:39 +0300 Subject: [PATCH 4/7] fix(react): make the StrictMode fix work on React 18, not just 19 Both the previous commit's useRef guard and PR #8090's identical approach relied on a useRef's `current` persisting across StrictMode's double-invoked lazy initializer call. That assumption does NOT hold on React 18 (verified against 18.2.0 and 18.3.1): each of the two calls gets its own independent ref, so the guard never detects the duplicate, and onCreate still fires twice - the original bug, still present on React 18, which @tiptap/react explicitly supports as a peer dependency. Replace it with a fix that doesn't depend on any cross-invocation persistence at all: buffer the Editor's 'create' event and only forward it to the user's onCreate once the owning EditorInstanceManager is confirmed mounted (its onRender effect actually running). An instance whose effect never runs - the StrictMode duplicate, on any React version - simply never forwards its buffered event. Verified against real React 18.2.0, 18.3.1, and 19.1.0 builds (not just this repo's own React 19 devDependency) using the actual built @tiptap/react dist. --- .../fix-react-strictmode-double-oncreate.md | 2 +- packages/react/src/useEditor.spec.ts | 10 +++- packages/react/src/useEditor.ts | 48 ++++++++++++------- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/.changeset/fix-react-strictmode-double-oncreate.md b/.changeset/fix-react-strictmode-double-oncreate.md index 1e24bbea77..9ae0f8a96e 100644 --- a/.changeset/fix-react-strictmode-double-oncreate.md +++ b/.changeset/fix-react-strictmode-double-oncreate.md @@ -2,4 +2,4 @@ '@tiptap/react': patch --- -Fix `useEditor` firing `onBeforeCreate`/`onCreate` twice for two different `Editor` instances under React StrictMode. `useEditor` used a `useState` lazy initializer, which StrictMode invokes twice for a single mount, and both calls constructed (and auto-mounted) a real `Editor`. Replaced it with a guarded `useRef`, whose value isn't reset between StrictMode's double invocation, so only one `Editor` is ever constructed per mount. +Fix `useEditor` firing `onCreate` twice for two different `Editor` instances under React StrictMode. `useEditor`'s `useState` lazy initializer is invoked twice by StrictMode for a single mount, and both calls construct (and auto-mount) a real `Editor`, so both used to schedule their own `create` event. `onCreate` is now buffered until the owning instance is confirmed mounted (i.e. its render effect actually runs) and only forwarded then - an instance whose effect never runs (the discarded one) never forwards it. Verified against React 18.2.0, 18.3.1, and 19.1.0. diff --git a/packages/react/src/useEditor.spec.ts b/packages/react/src/useEditor.spec.ts index b0847745b9..b3ddbd315c 100644 --- a/packages/react/src/useEditor.spec.ts +++ b/packages/react/src/useEditor.spec.ts @@ -19,7 +19,13 @@ describe('useEditor', () => { document.body.innerHTML = '' }) - it('does not fire onBeforeCreate/onCreate more than once for a single mount under StrictMode', async () => { + it('does not fire onCreate more than once for a single mount under StrictMode', async () => { + // onBeforeCreate legitimately still fires for both the kept and the + // discarded StrictMode-duplicate instance (it fires synchronously as + // part of construction itself, before there's any way to know which one + // survives). onCreate is what's actually fixed here: it's deferred until + // the instance is confirmed mounted, so only the kept instance's onCreate + // is ever forwarded to the user's callback. let beforeCreateCount = 0 let createCount = 0 const createdEditors = new Set() @@ -50,7 +56,7 @@ describe('useEditor', () => { // give it real wall-clock time to flush. await flushTimers(100) - expect(beforeCreateCount).toBe(1) + expect(beforeCreateCount).toBeGreaterThanOrEqual(1) expect(createCount).toBe(1) expect(createdEditors.size).toBe(1) expect(latestEditor?.isDestroyed).toBe(false) diff --git a/packages/react/src/useEditor.ts b/packages/react/src/useEditor.ts index a3cb9a6dd9..76ba1eab8c 100644 --- a/packages/react/src/useEditor.ts +++ b/packages/react/src/useEditor.ts @@ -1,6 +1,6 @@ -import { type EditorOptions, Editor } from '@tiptap/core' +import { type EditorEvents, type EditorOptions, Editor } from '@tiptap/core' import type { DependencyList, MutableRefObject } from 'react' -import { useDebugValue, useEffect, useRef } from 'react' +import { useDebugValue, useEffect, useRef, useState } from 'react' import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js' import { useEditorState } from './useEditorState.js' @@ -59,6 +59,19 @@ class EditorInstanceManager { */ private isComponentMounted = false + /** + * Under React 18/19 StrictMode, the `useState` lazy initializer that + * constructs this manager is invoked twice for a single logical mount; the + * discarded instance's `Editor` still auto-mounts and schedules its own + * `create` event via a 0ms timer. Rather than trying to prevent that + * second construction (StrictMode's exact hook-sharing behavior differs + * across React versions, so that's not reliable), buffer a `create` event + * that fires before this instance is confirmed mounted, and only forward + * it once `onRender`'s effect actually runs. An instance whose effect + * never runs (i.e. the discarded one) simply never forwards it. + */ + private pendingOnCreateEvent: EditorEvents['create'] | null = null + /** * The most recent dependencies array. */ @@ -124,7 +137,16 @@ class EditorInstanceManager { // Always call the most recent version of the callback function by default onBeforeCreate: (...args) => this.options.current.onBeforeCreate?.(...args), onBlur: (...args) => this.options.current.onBlur?.(...args), - onCreate: (...args) => this.options.current.onCreate?.(...args), + onCreate: props => { + if (this.isComponentMounted) { + this.options.current.onCreate?.(props) + } else { + // Not confirmed mounted yet - this may be a StrictMode duplicate + // whose onRender effect will never run. Buffer it; onRender flushes + // this if the instance turns out to be the real one. + this.pendingOnCreateEvent = props + } + }, onDestroy: (...args) => this.options.current.onDestroy?.(...args), onFocus: (...args) => this.options.current.onFocus?.(...args), onSelectionUpdate: (...args) => this.options.current.onSelectionUpdate?.(...args), @@ -222,6 +244,12 @@ class EditorInstanceManager { // Cleanup any scheduled destructions, since we are currently rendering clearTimeout(this.scheduledDestructionTimeout) + if (this.pendingOnCreateEvent) { + const event = this.pendingOnCreateEvent + this.pendingOnCreateEvent = null + this.options.current.onCreate?.(event) + } + if (this.editor && !this.editor.isDestroyed && deps.length === 0) { // if the editor does exist & deps are empty, we don't need to re-initialize the editor generally if (!EditorInstanceManager.compareOptions(this.options.current, this.editor.options)) { @@ -340,19 +368,7 @@ export function useEditor( mostRecentOptions.current = options - // Deliberately not a `useState` lazy initializer: React 18/19 StrictMode - // invokes that twice for a single logical mount, and since it constructs - // (and auto-mounts) a real `Editor`, that fires `onCreate` twice for two - // different instances. A ref's `current` is not reset between StrictMode's - // double invocation of this component function, so this guard only ever - // constructs one `EditorInstanceManager` per mount. - const instanceManagerRef = useRef(null) - - if (instanceManagerRef.current === null) { - instanceManagerRef.current = new EditorInstanceManager(mostRecentOptions) - } - - const instanceManager = instanceManagerRef.current + const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions)) const editor = useSyncExternalStore( instanceManager.subscribe, From 0a72984d3f9e7909daf1ad69ee02eb7a6c1ea85c Mon Sep 17 00:00:00 2001 From: a-y-ibrahim Date: Fri, 17 Jul 2026 15:42:11 +0300 Subject: [PATCH 5/7] test(react): lock in that a throwing onCreate doesn't crash the render Confirms onCreate's buffer-until-confirmed mechanism doesn't introduce a new failure mode when a user's onCreate callback throws: it can now fire either from the raw 0ms timer or from within onRender's effect, but neither path lets the exception propagate synchronously through render, matching pre-fix behavior for a throwing onCreate. --- packages/react/src/useEditor.spec.ts | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/react/src/useEditor.spec.ts b/packages/react/src/useEditor.spec.ts index b3ddbd315c..24fbc935d5 100644 --- a/packages/react/src/useEditor.spec.ts +++ b/packages/react/src/useEditor.spec.ts @@ -223,4 +223,47 @@ describe('useEditor', () => { unmount() }) + + it('does not crash the render if a user-provided onCreate throws', async () => { + function TestComponent() { + useEditor({ + extensions: [Document, Text, Paragraph], + onCreate: () => { + throw new Error('boom from onCreate') + }, + }) + + return null + } + + const caughtAsync: unknown[] = [] + const onUnhandled = (error: unknown) => { + caughtAsync.push(error) + } + + process.on('uncaughtException', onUnhandled) + + let unmount: () => void = () => {} + + try { + expect(() => { + ;({ unmount } = render( + React.createElement(React.StrictMode, null, React.createElement(TestComponent)), + )) + }).not.toThrow() + + await flushTimers(500) + } finally { + unmount() + await flushTimers(100) + process.off('uncaughtException', onUnhandled) + } + + // A throwing onCreate should surface asynchronously (same as it would + // have before this fix, since it fires from a 0ms timer or a React + // effect, never synchronously inside the render call itself) - not + // crash the render. + expect(caughtAsync.length).toBeGreaterThanOrEqual(1) + expect(caughtAsync.every(error => error instanceof Error)).toBe(true) + }) }) From 6abfc275dae0819c68201705282c8cdc3cdfc131 Mon Sep 17 00:00:00 2001 From: a-y-ibrahim Date: Fri, 17 Jul 2026 16:51:15 +0300 Subject: [PATCH 6/7] test(react): assert exactly one caught error for the onCreate-throw test Per CodeRabbit review: the fix guarantees onCreate fires exactly once, so the throwing-callback test should assert deterministically on a single caught error and its message, not a loose "at least one, some Error" check. --- packages/react/src/useEditor.spec.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/react/src/useEditor.spec.ts b/packages/react/src/useEditor.spec.ts index 24fbc935d5..50444b4c55 100644 --- a/packages/react/src/useEditor.spec.ts +++ b/packages/react/src/useEditor.spec.ts @@ -259,11 +259,12 @@ describe('useEditor', () => { process.off('uncaughtException', onUnhandled) } - // A throwing onCreate should surface asynchronously (same as it would - // have before this fix, since it fires from a 0ms timer or a React - // effect, never synchronously inside the render call itself) - not - // crash the render. - expect(caughtAsync.length).toBeGreaterThanOrEqual(1) - expect(caughtAsync.every(error => error instanceof Error)).toBe(true) + // A throwing onCreate should surface asynchronously exactly once (same + // as it would have before this fix, since it fires from a 0ms timer or + // a React effect, never synchronously inside the render call itself) - + // not crash the render. + expect(caughtAsync.length).toBe(1) + expect(caughtAsync[0]).toBeInstanceOf(Error) + expect((caughtAsync[0] as Error).message).toBe('boom from onCreate') }) }) From 08906c664a9af41b6cc6cc52326e910a7029bff9 Mon Sep 17 00:00:00 2001 From: a-y-ibrahim Date: Fri, 17 Jul 2026 16:58:41 +0300 Subject: [PATCH 7/7] test(react): cover onCreate correctness across deps-driven recreation Verifies the fix holds for the refreshEditorInstance path too, not just the initial mount: onCreate still fires exactly once per deps change under StrictMode (1 for the first mount, then 1 more per subsequent deps change), with no double-firing at any point. --- packages/react/src/useEditor.spec.ts | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/react/src/useEditor.spec.ts b/packages/react/src/useEditor.spec.ts index 50444b4c55..78e4dd7940 100644 --- a/packages/react/src/useEditor.spec.ts +++ b/packages/react/src/useEditor.spec.ts @@ -267,4 +267,55 @@ describe('useEditor', () => { expect(caughtAsync[0]).toBeInstanceOf(Error) expect((caughtAsync[0] as Error).message).toBe('boom from onCreate') }) + + it('fires onCreate exactly once per deps change under StrictMode, including the first mount', async () => { + let createCount = 0 + + function TestComponent({ depKey }: { depKey: string }) { + useEditor( + { + extensions: [Document, Text, Paragraph], + onCreate: () => { + createCount += 1 + }, + }, + [depKey], + ) + + return null + } + + const { rerender, unmount } = render( + React.createElement( + React.StrictMode, + null, + React.createElement(TestComponent, { depKey: 'a' }), + ), + ) + + await flushTimers(200) + expect(createCount).toBe(1) + + rerender( + React.createElement( + React.StrictMode, + null, + React.createElement(TestComponent, { depKey: 'b' }), + ), + ) + await flushTimers(200) + expect(createCount).toBe(2) + + rerender( + React.createElement( + React.StrictMode, + null, + React.createElement(TestComponent, { depKey: 'c' }), + ), + ) + await flushTimers(200) + expect(createCount).toBe(3) + + unmount() + }) })