Skip to content

fix(react): prevent useEditorState selectors from running on stale editor#7586

Open
omar-y-abdi wants to merge 1 commit intoueberdosis:mainfrom
omar-y-abdi:fix/use-editor-state-stale-editor
Open

fix(react): prevent useEditorState selectors from running on stale editor#7586
omar-y-abdi wants to merge 1 commit intoueberdosis:mainfrom
omar-y-abdi:fix/use-editor-state-stale-editor

Conversation

@omar-y-abdi
Copy link

Summary

Fixes #7346

When useEditor deps change, the old editor is destroyed and a new one is created. EditorStateManager in useEditorState only updated its editor reference via a layout effect (watch), which fires after render. This caused useSyncExternalStoreWithSelector to call selectors with a snapshot containing the old, destroyed editor — leading to:

[tiptap error]: The editor view is not available. Cannot access view['hasFocus'].

Root Cause

The lifecycle ordering when deps change:

  1. useEditor effect runs → refreshEditorInstance destroys old editor, creates new one
  2. setEditor notifies useSyncExternalStore → React re-renders
  3. During re-render, useEditorState's useSyncExternalStoreWithSelector calls getSnapshot()
  4. Bug: EditorStateManager still holds the old (destroyed) editor — watch hasn't fired yet (layout effect)
  5. Selector runs against destroyed editor → error

Fix

Added setEditorInstance() to EditorStateManager that eagerly syncs the editor reference during render, before useSyncExternalStoreWithSelector reads the snapshot. The layout effect (watch) still handles transaction listener setup/teardown as before.

The change is 2 additions:

  • A setEditorInstance method on EditorStateManager (7 lines)
  • One call to it in useEditorState before the store selector runs (1 line + comments)

Testing

  • 4 new unit tests in packages/react/src/__tests__/useEditorState.test.ts
    • Selector returns correct result for current editor
    • Selector never runs against a destroyed editor when editor instance changes (the bug scenario)
    • Editor changing to null
    • Editor changing from null to an instance
  • Tests fail without the fix (3/4 fail), confirming they capture the bug
  • All 657 existing tests pass (zero regressions)
  • Build passes (72/72 packages)
  • Lint + prettier clean

…itor

When `useEditor` deps change, the old editor is destroyed and a new one
is created. The `EditorStateManager` in `useEditorState` only updated
its editor reference via a layout effect (`watch`), which fires after
render. This caused `useSyncExternalStoreWithSelector` to call selectors
with a snapshot containing the old, destroyed editor — leading to errors
like "The editor view is not available".

Fix: eagerly sync the editor reference during render via a new
`setEditorInstance` method, called before the selector runs. The layout
effect still handles transaction listener setup/teardown.

Fixes ueberdosis#7346
Copilot AI review requested due to automatic review settings March 12, 2026 20:21
@netlify
Copy link

netlify bot commented Mar 12, 2026

Deploy Preview for tiptap-embed ready!

Name Link
🔨 Latest commit ec6607b
🔍 Latest deploy log https://app.netlify.com/projects/tiptap-embed/deploys/69b320404df2c900084f9fb2
😎 Deploy Preview https://deploy-preview-7586--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.

@changeset-bot
Copy link

changeset-bot bot commented Mar 12, 2026

⚠️ No Changeset found

Latest commit: ec6607b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a React hook edge case where useEditorState selectors could run against a stale/destroyed editor instance during useEditor re-creation, by ensuring the EditorStateManager’s editor reference is synchronized before useSyncExternalStoreWithSelector reads the snapshot.

Changes:

  • Added EditorStateManager.setEditorInstance() to eagerly sync the current editor reference during render (and bump the snapshot version).
  • Called setEditorInstance() in useEditorState prior to useSyncExternalStoreWithSelector to avoid stale snapshots.
  • Added unit tests covering editor instance swaps (including null ↔ instance) and ensuring selectors don’t run on a destroyed editor.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
packages/react/src/useEditorState.ts Eagerly updates the stored editor reference during render so getSnapshot() cannot return a stale/destroyed editor when the instance changes.
packages/react/src/tests/useEditorState.test.ts Adds hook-level regression tests reproducing the destroyed-editor selector scenario and related editor lifecycle transitions.

You can also share your feedback on Copilot code review. Take the survey.

@bdbch bdbch changed the base branch from develop to main March 14, 2026 15:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Updating useEditor deps causes useEditorState selectors to run with an uninitialized editor

2 participants