Skip to content

fix(react): onCreate fired twice under React.StrictMode in useEditor - #8095

Open
a-y-ibrahim wants to merge 7 commits into
ueberdosis:mainfrom
a-y-ibrahim:fix/react-strictmode-double-oncreate
Open

fix(react): onCreate fired twice under React.StrictMode in useEditor#8095
a-y-ibrahim wants to merge 7 commits into
ueberdosis:mainfrom
a-y-ibrahim:fix/react-strictmode-double-oncreate

Conversation

@a-y-ibrahim

@a-y-ibrahim a-y-ibrahim commented Jul 17, 2026

Copy link
Copy Markdown

Fixes

Fixes #7091

Changes and Review

useEditor()'s EditorInstanceManager is created via a useState lazy initializer. React 18/19 StrictMode invokes that initializer twice for a single mount, and both calls construct (and auto-mount, since Editor defaults to a detached div) a real Editor, so both used to schedule their own create event.

onCreate is now buffered until the owning EditorInstanceManager is confirmed mounted (i.e. its render effect actually runs), and only forwarded to the user's callback then. An instance whose effect never runs - the StrictMode duplicate - simply never forwards its buffered event. This doesn't depend on any cross-invocation persistence (e.g. a ref surviving StrictMode's double-invoke), which turned out not to be reliable across React versions: verified against real React 17.0.2, 18.2.0, 18.3.1, and 19.1.0 builds using the actual built @tiptap/react dist, not just this repo's own React 19 devDependency.

Added useEditor.spec.ts (@testing-library/react, matching BubbleMenu.spec.ts's existing pattern) covering: single onCreate under StrictMode, independent sibling editors, immediatelyRender: false, a full unmount/remount cycle, and mounting without StrictMode.

Checklist

  • I have added a changeset if necessary.
  • I have added tests if possible.
  • I have made sure to test my changes myself.

Responsibility

  • I have reviewed and understand these changes, and I take responsibility for this PR, even if an AI agent created it.

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 ueberdosis#7091
@a-y-ibrahim
a-y-ibrahim requested a review from a team as a code owner July 17, 2026 00:42
Copilot AI review requested due to automatic review settings July 17, 2026 00:42
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 08906c6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 72 packages
Name Type
@tiptap/react Patch
@tiptap/extension-drag-handle-react Patch
@tiptap/core Patch
@tiptap/extension-audio Patch
@tiptap/extension-blockquote Patch
@tiptap/extension-bold Patch
@tiptap/extension-bubble-menu Patch
@tiptap/extension-bullet-list Patch
@tiptap/extension-code-block-lowlight Patch
@tiptap/extension-code-block Patch
@tiptap/extension-code Patch
@tiptap/extension-collaboration-caret Patch
@tiptap/extension-collaboration Patch
@tiptap/extension-color Patch
@tiptap/extension-details Patch
@tiptap/extension-document Patch
@tiptap/extension-drag-handle-vue-2 Patch
@tiptap/extension-drag-handle-vue-3 Patch
@tiptap/extension-drag-handle Patch
@tiptap/extension-emoji Patch
@tiptap/extension-file-handler Patch
@tiptap/extension-floating-menu Patch
@tiptap/extension-font-family Patch
@tiptap/extension-hard-break Patch
@tiptap/extension-heading Patch
@tiptap/extension-highlight Patch
@tiptap/extension-horizontal-rule Patch
@tiptap/extension-image Patch
@tiptap/extension-invisible-characters Patch
@tiptap/extension-italic Patch
@tiptap/extension-link Patch
@tiptap/extension-list Patch
@tiptap/extension-mathematics Patch
@tiptap/extension-mention Patch
@tiptap/extension-node-range Patch
@tiptap/extension-ordered-list Patch
@tiptap/extension-paragraph Patch
@tiptap/extension-strike Patch
@tiptap/extension-subscript Patch
@tiptap/extension-superscript Patch
@tiptap/extension-table-of-contents Patch
@tiptap/extension-table Patch
@tiptap/extension-text-align Patch
@tiptap/extension-text-style Patch
@tiptap/extension-text Patch
@tiptap/extension-twitch Patch
@tiptap/extension-typography Patch
@tiptap/extension-underline Patch
@tiptap/extension-unique-id Patch
@tiptap/extension-youtube Patch
@tiptap/extensions Patch
@tiptap/html Patch
@tiptap/markdown Patch
@tiptap/pm Patch
@tiptap/starter-kit Patch
@tiptap/static-renderer Patch
@tiptap/suggestion Patch
@tiptap/vue-2 Patch
@tiptap/vue-3 Patch
@tiptap/extension-character-count Patch
@tiptap/extension-dropcursor Patch
@tiptap/extension-focus Patch
@tiptap/extension-gapcursor Patch
@tiptap/extension-history Patch
@tiptap/extension-list-item Patch
@tiptap/extension-list-keymap Patch
@tiptap/extension-placeholder Patch
@tiptap/extension-table-cell Patch
@tiptap/extension-table-header Patch
@tiptap/extension-table-row Patch
@tiptap/extension-task-item Patch
@tiptap/extension-task-list Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@netlify

netlify Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deploy Preview for tiptap-embed ready!

Name Link
🔨 Latest commit 08906c6
🔍 Latest deploy log https://app.netlify.com/projects/tiptap-embed/deploys/6a5a352967728800085c0223
😎 Deploy Preview https://deploy-preview-8095--tiptap-embed.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

useEditor now buffers early onCreate events until the editor instance is confirmed mounted. Tests cover StrictMode, sibling editors, deferred rendering, normal mounting, remount behavior, and asynchronous callback errors. A patch changeset documents the fix.

Changes

React StrictMode lifecycle

Layer / File(s) Summary
Deferred create event handling
packages/react/src/useEditor.ts, .changeset/fix-react-strictmode-double-oncreate.md
Buffers onCreate until the render effect confirms the mounted editor instance, preventing discarded StrictMode instances from forwarding the event.
StrictMode lifecycle regression tests
packages/react/src/useEditor.spec.ts
Tests single and sibling editor creation, non-StrictMode behavior, immediatelyRender: false, remount lifecycle counts, and asynchronous errors from onCreate.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: area: react, complexity: medium, impact: medium

Sequence Diagram(s)

sequenceDiagram
  participant React
  participant EditorInstanceManager
  participant Editor
  React->>EditorInstanceManager: initialize useEditor
  Editor->>EditorInstanceManager: emit create
  EditorInstanceManager->>EditorInstanceManager: buffer create event
  React->>EditorInstanceManager: run mount effect
  EditorInstanceManager-->>React: forward onCreate once
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main fix: onCreate firing twice in useEditor under React.StrictMode.
Description check ✅ Passed The description matches the template and includes fixes, changes, checklist items, and the linked issue.
Linked Issues check ✅ Passed The PR addresses #7091 by buffering onCreate until mount, which prevents the StrictMode duplicate editor from firing twice.
Out of Scope Changes check ✅ Passed The changes stay within scope: bug fix, tests, and a changeset for the affected react package.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added area: react React integration and components complexity: medium Moderate change, possibly multiple files impact: medium Affects some users or workflows labels Jul 17, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react/src/useEditor.spec.ts`:
- Around line 196-230: Update the test around TestComponent and render to
capture the returned unmount function, ensure only the discarded StrictMode
duplicate invokes the throwing onDestroy, then call unmount and await
flushTimers before process.off('uncaughtException'). Keep the assertions
verifying rendering does not throw synchronously and the deferred error is
surfaced.

In `@packages/react/src/useEditor.ts`:
- Around line 98-110: Update the cleanup logic around Editor.destroy() to retain
a local reference to the discarded editor and, when destroy throws, attempt that
editor’s unmount() before asynchronously rethrowing the original callback error.
Preserve clearing this.editor in all cases, and add a test verifying a discarded
editor is fully unmounted, including its view and CSS cleanup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 340bbd64-dc57-4aff-bb5e-add9b8c0c502

📥 Commits

Reviewing files that changed from the base of the PR and between d26840f and 1f9ca89.

📒 Files selected for processing (3)
  • .changeset/fix-react-strictmode-double-oncreate.md
  • packages/react/src/useEditor.spec.ts
  • packages/react/src/useEditor.ts

Comment thread packages/react/src/useEditor.spec.ts Outdated
Comment thread packages/react/src/useEditor.ts Outdated
Comment on lines +98 to +110
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Finish cleanup when onDestroy throws.

Editor.destroy() emits destroy before calling unmount(). If onDestroy throws, this catch clears the manager reference without unmounting the discarded editor, potentially leaving its view and CSS behind.

Please keep a local editor reference and attempt unmount() in the error path before rethrowing the callback error asynchronously. Add a test that verifies the discarded editor is fully unmounted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react/src/useEditor.ts` around lines 98 - 110, Update the cleanup
logic around Editor.destroy() to retain a local reference to the discarded
editor and, when destroy throws, attempt that editor’s unmount() before
asynchronously rethrowing the original callback error. Preserve clearing
this.editor in all cases, and add a test verifying a discarded editor is fully
unmounted, including its view and CSS cleanup.

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.
@a-y-ibrahim a-y-ibrahim changed the title fix(react): prevent onCreate firing twice for StrictMode duplicate fix(react): onCreate fired twice under React.StrictMode in useEditor Jul 17, 2026
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.
Both the previous commit's useRef guard and PR ueberdosis#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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react/src/useEditor.ts`:
- Around line 247-251: Update the pendingOnCreateEvent flush in onRender to
verify the editor has not been destroyed before invoking
options.current.onCreate. Preserve clearing pendingOnCreateEvent, and ensure
destroyed editors do not emit the stale onCreate event.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: f6af352b-6bea-49b3-adf3-a6f233dff8a8

📥 Commits

Reviewing files that changed from the base of the PR and between 5227c55 and c08706d.

📒 Files selected for processing (3)
  • .changeset/fix-react-strictmode-double-oncreate.md
  • packages/react/src/useEditor.spec.ts
  • packages/react/src/useEditor.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • .changeset/fix-react-strictmode-double-oncreate.md
  • packages/react/src/useEditor.spec.ts

Comment on lines +247 to +251
if (this.pendingOnCreateEvent) {
const event = this.pendingOnCreateEvent
this.pendingOnCreateEvent = null
this.options.current.onCreate?.(event)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against flushing onCreate for a destroyed editor.

If scheduleDestroy's 1ms timeout fires before onRender runs (unlikely but possible when the event loop is congested), the editor is destroyed while pendingOnCreateEvent still holds a stale reference. The subsequent onRender would then call onCreate with a destroyed editor, which is confusing for users.

Adding a destroyed check is a one-line defensive guard:

🛡️ Proposed fix
       if (this.pendingOnCreateEvent) {
         const event = this.pendingOnCreateEvent
         this.pendingOnCreateEvent = null
-        this.options.current.onCreate?.(event)
+        if (!event.editor.isDestroyed) {
+          this.options.current.onCreate?.(event)
+        }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (this.pendingOnCreateEvent) {
const event = this.pendingOnCreateEvent
this.pendingOnCreateEvent = null
this.options.current.onCreate?.(event)
}
if (this.pendingOnCreateEvent) {
const event = this.pendingOnCreateEvent
this.pendingOnCreateEvent = null
if (!event.editor.isDestroyed) {
this.options.current.onCreate?.(event)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react/src/useEditor.ts` around lines 247 - 251, Update the
pendingOnCreateEvent flush in onRender to verify the editor has not been
destroyed before invoking options.current.onCreate. Preserve clearing
pendingOnCreateEvent, and ensure destroyed editors do not emit the stale
onCreate event.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react/src/useEditor.spec.ts`:
- Around line 266-267: Update the assertions in the useEditor test to verify
exactly one caught error, and assert that its message matches the expected
onCreate error. Replace the broad Error-instance and count checks around
caughtAsync with deterministic validation of the single delivered error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 149994f9-22e5-43ef-b8ff-5d044782387a

📥 Commits

Reviewing files that changed from the base of the PR and between c08706d and 0a72984.

📒 Files selected for processing (1)
  • packages/react/src/useEditor.spec.ts

Comment thread packages/react/src/useEditor.spec.ts Outdated
@arnaugomez

arnaugomez commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

I'm of the opinion that onCreate running twice is not a bug - it should be the default behavior, because it helps enforce React best practices and it aligns with its philosophy of keeping initializers free from side-effects.

See my comment here: #7091 (comment)

@bdbch let me know if you agree on that. Perhaps I'm wrong.

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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: react React integration and components complexity: medium Moderate change, possibly multiple files impact: medium Affects some users or workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UseEditor onCreate event is called twice with two different editor instances in React.StrictMode

3 participants