Skip to content

[lexical-react] Bug Fix: the draggable block handle follows setEditable - #9012

Closed
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:fix/draggable-block-editable
Closed

[lexical-react] Bug Fix: the draggable block handle follows setEditable#9012
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:fix/draggable-block-editable

Conversation

@LeSingh1

@LeSingh1 LeSingh1 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Description

DraggableBlockPlugin_EXPERIMENTAL takes the editability it renders on straight off the editor's private field, during render, with no subscription:

const [editor] = useLexicalComposerContext();
return useDraggableBlockMenu(
  editor,
  anchorElem,
  menuRef,
  targetLineRef,
  editor._editable,   // <- plain field read
  ...

editor.setEditable(...) notifies editable listeners; it does not schedule a React render for a component that merely read the field. So the value the plugin renders on is whatever it happened to be at the last render:

  • Switching an editor to read-only leaves the drag handle rendered — {isEditable && menuComponent} is still true — and fully functional. Dragging still runs $onDrop, which calls targetNode.insertAfter(...) / insertBefore(...), so a read-only document can be reordered by dragging.
  • The reverse also breaks: an editor mounted with editable: false and later made editable never grows a handle.
  • The Firefox blur guard inside the hook is gated on the same stale value, so it registers against the wrong state.

It is intermittent, which is the usual signature: any unrelated re-render of the plugin's parent silently corrects it until the next toggle.

@lexical/react already ships the hook for this, and its doc comment is explicit:

Get the current value for LexicalEditor.isEditable using useLexicalSubscription. You should prefer this over manually observing the value with LexicalEditor.registerEditableListener, which is a bit tricky to do correctly, particularly when using React StrictMode (the default for development) or concurrency.

Every other editability consumer in the package uses it — LexicalRichTextPlugin, ContentEditableElement, LexicalContentEditable — and editor._editable is the only _editable reference in packages/lexical-react/src. This swaps it for useLexicalEditable(). The hook's signature is unchanged (it already takes isEditable as a parameter), so this is internal to the exported component; no API or serialization change.

This is reachable in the shipped playground, which renders DraggableBlockPlugin unconditionally and has a read-only toggle calling editor.setEditable(!editor.isEditable()).

Test plan

New unit test packages/lexical-react/src/__tests__/unit/LexicalDraggableBlockPlugin.test.tsx renders the plugin and toggles editability in both directions.

Before

 ❯ packages/lexical-react/src/__tests__/unit/LexicalDraggableBlockPlugin.test.tsx (2 tests | 2 failed)
   × follows setEditable
     AssertionError: expected true to be false // Object.is equality
   × renders no drag handle for an editor that mounts read-only
     AssertionError: expected false to be true // Object.is equality

 Test Files  1 failed (1)
      Tests  2 failed (2)

After

 Test Files  1 passed (1)
      Tests  2 passed (2)

Package suite is unchanged:

$ npx vitest run packages/lexical-react
 Test Files  32 passed (32)
      Tests  184 passed (184)

## Description

`DraggableBlockPlugin_EXPERIMENTAL` takes the editability it renders on straight off the editor's private field, during render, with no subscription:

```tsx
const [editor] = useLexicalComposerContext();
return useDraggableBlockMenu(
  editor,
  anchorElem,
  menuRef,
  targetLineRef,
  editor._editable,   // <- plain field read
  ...
```

`editor.setEditable(...)` notifies editable listeners; it does not schedule a React render for a component that merely read the field. So the value the plugin renders on is whatever it happened to be at the last render:

- Switching an editor to read-only leaves the drag handle rendered — `{isEditable && menuComponent}` is still true — and fully functional. Dragging still runs `$onDrop`, which calls `targetNode.insertAfter(...)` / `insertBefore(...)`, so a read-only document can be reordered by dragging.
- The reverse also breaks: an editor mounted with `editable: false` and later made editable never grows a handle.
- The Firefox blur guard inside the hook is gated on the same stale value, so it registers against the wrong state.

It is intermittent, which is the usual signature: any unrelated re-render of the plugin's parent silently corrects it until the next toggle.

`@lexical/react` already ships the hook for this, and its doc comment is explicit:

> Get the current value for `LexicalEditor.isEditable` using `useLexicalSubscription`. You should prefer this over manually observing the value with `LexicalEditor.registerEditableListener`, which is a bit tricky to do correctly, particularly when using React StrictMode (the default for development) or concurrency.

Every other editability consumer in the package uses it — `LexicalRichTextPlugin`, `ContentEditableElement`, `LexicalContentEditable` — and `editor._editable` is the only `_editable` reference in `packages/lexical-react/src`. This swaps it for `useLexicalEditable()`. The hook's signature is unchanged (it already takes `isEditable` as a parameter), so this is internal to the exported component; no API or serialization change.

This is reachable in the shipped playground, which renders `DraggableBlockPlugin` unconditionally and has a read-only toggle calling `editor.setEditable(!editor.isEditable())`.

## Test plan

New unit test `packages/lexical-react/src/__tests__/unit/LexicalDraggableBlockPlugin.test.tsx` renders the plugin and toggles editability in both directions.

### Before

```
 ❯ packages/lexical-react/src/__tests__/unit/LexicalDraggableBlockPlugin.test.tsx (2 tests | 2 failed)
   × follows setEditable
     AssertionError: expected true to be false // Object.is equality
   × renders no drag handle for an editor that mounts read-only
     AssertionError: expected false to be true // Object.is equality

 Test Files  1 failed (1)
      Tests  2 failed (2)
```

### After

```
 Test Files  1 passed (1)
      Tests  2 passed (2)
```

Package suite is unchanged:

```
$ npx vitest run packages/lexical-react
 Test Files  32 passed (32)
      Tests  184 passed (184)
```
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

@LeSingh1 is attempting to deploy a commit to the Meta Open Source Team on Vercel.

A member of the Team first needs to authorize it.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 9, 2026
@LeSingh1

Copy link
Copy Markdown
Contributor Author

Consolidated into #9051 with the other PRs that share this defect, per @etrepum's note on #9027 and @mayrang's on #9035. Same fix and same tests, one review.

@LeSingh1 LeSingh1 closed this Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant