Skip to content

[lexical-react][lexical-playground] Bug Fix: plugins and hooks re-derive when their inputs change - #9051

Open
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:consol/react-plugin-resubscribe
Open

[lexical-react][lexical-playground] Bug Fix: plugins and hooks re-derive when their inputs change#9051
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:consol/react-plugin-resubscribe

Conversation

@LeSingh1

Copy link
Copy Markdown
Contributor

Description

A React plugin or hook that reads editor state once, on the first render, and
then subscribes for future changes only reports a stale answer for everything
that happened before it subscribed — and never re-derives when the prop or
argument it was seeded from changes. registerUpdateListener and friends do not
fire on registration, an effect whose dependency list omits the prop that changed
never re-runs, and a value read off the editor at render time (editor._editable,
editor.isEditable()) is not a subscription at all. The same shape shows up on
the handler side: a command handler that claims the event when there is nothing
to act on, and a state update that clobbers the selection it was supposed to
leave alone.

Each of the fixes below re-derives (or resubscribes) from the input that actually
changed, and none of them changes behaviour when the inputs are stable.

  • CollaborationPlugin (packages/lexical-react/src/LexicalCollaborationPlugin.tsx)
    guarded provider creation with an isProviderInitialized ref, so a changed
    providerFactory, id or yjsDocMap kept the old provider forever. The ref
    now records the inputs that produced the current provider, so a real change
    creates a new one while a StrictMode/remount re-run with unchanged inputs still
    does not.
  • useMenuAnchorRef (packages/lexical-react/src/shared/LexicalMenu.tsx)
    positioned the absolutely-positioned anchor in document coordinates
    unconditionally. When a parent is passed it is normally positioned, so it —
    not the initial containing block — is the anchor's containing block, and the
    menu landed at the parent's own offset instead of at the caret. New
    getContainingBlockOrigin() resolves the real origin and the toAnchorLeft/
    toAnchorTop helpers translate every viewport coordinate through it, including
    the two flip-into-view branches. Fixes Bug: Menu Selection and Scrolling Misbehavior in Limited Lexical Editor Space #6989.
  • DraggableBlockPlugin_EXPERIMENTAL
    (packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx) read
    editor._editable during render, which is not a subscription, so the handle
    did not appear or disappear on setEditable(). It now uses
    useLexicalEditable().
  • LexicalMenu (packages/lexical-react/src/shared/LexicalMenu.tsx) returned
    true from its KEY_ARROW_DOWN_COMMAND/KEY_ARROW_UP_COMMAND handlers even
    when options was empty — no menu is rendered in that case, so the arrow keys
    were swallowed and the caret stopped moving. Both handlers now return false
    when there is nothing to move through.
  • NodeContextMenuPlugin
    (packages/lexical-react/src/LexicalNodeContextMenuPlugin.tsx) called
    preventDefault() and mounted its scroll-locking overlay before computing
    which items $showOn allows, so a node with no applicable items suppressed the
    browser's own context menu and showed an empty one. The filtering moved ahead
    of preventDefault() and the handler returns early when nothing is visible.
  • useLexicalIsTextContentEmpty
    (packages/lexical-react/src/useLexicalIsTextContentEmpty.ts) seeded its state
    on the first render only, so a new editor or a new trim kept reporting the
    previous answer until the next update. The layout effect now re-derives the
    value before subscribing (and the initial useState is lazy, so it no longer
    reads the editor on every render).
  • useLexicalNodeSelection
    (packages/lexical-react/src/useLexicalNodeSelection.ts) created an empty
    NodeSelection when asked to deselect a node while the user held a
    RangeSelection, discarding their caret. Deselecting with no NodeSelection
    present is now a no-op.
  • The playground's TextFormatFloatingToolbar
    (packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx)
    gated its buttons on editor.isEditable() read during render, so the toolbar
    kept its format buttons after setEditable(false). It now uses
    useLexicalEditable().
  • useCharacterLimit (packages/lexical-react/src/shared/useCharacterLimit.ts)
    only ever counted inside registerTextContentListener, which does not fire on
    registration, so an editor that mounts with content reported the full budget
    remaining and left overflowing text unwrapped until the next keystroke. The
    body is extracted into $updateCharacterLimit() and called once before
    subscribing.
  • TreeView (packages/lexical-react/src/LexicalTreeView.tsx) seeded
    editorCurrentState on the first render only and its effect had editor in
    the deps but never re-read the state, so a changed editor prop kept rendering
    the previous editor's tree. The effect re-reads editor.getEditorState()
    before subscribing, and is a useLayoutEffect to match
    useCanShowPlaceholder/ContentEditableElement and avoid a cascading render.

Test plan

Nine new unit test files and one new browser test file, one per fix, each
asserting the stale value is re-derived (or that the handler declines to act);
packages/lexical-react/src/__tests__/unit/LexicalCollaborationPlugin.test.tsx
gains a case for the provider swap. Every test fails on main with only the test
files applied and passes with the fixes.

Before

Source fixes reverted, new tests kept:

$ npx vitest run --project unit packages/lexical-react packages/lexical-playground
     × leaves the native context menu alone when no item is shown 275ms
     × keeps a range selection when asked to deselect 88ms
     × hides its format buttons when the editor becomes read-only 206ms
     × counts the text that is already in the editor when it mounts 95ms
     × reports a full budget for an editor that is under the limit 8ms
     × counts in the charset it was given 6ms
     × follows setEditable 143ms
     × renders no drag handle for an editor that mounts read-only 17ms
     × lets ArrowDown through when there are no options 63ms
     × lets ArrowUp through when there are no options 4ms
     × provider is replaced when providerFactory changes 7ms
     × follows a change of the trim argument 19ms
     × follows a change of the editor 5ms
     × re-renders when the editor prop changes 14ms
 Test Files  9 failed | 49 passed (58)
      Tests  14 failed | 470 passed (484)

$ npx vitest run --project browser packages/lexical-react/src/__tests__/browser/useMenuAnchorRefPosition.test.tsx
     × anchors the menu at the caret when parent is a positioned element 147ms
     × anchors the menu at the caret when the positioned parent is scrolled 99ms
 Test Files  1 failed (1)
      Tests  2 failed | 1 passed (3)

After

$ npx vitest run --project unit packages/lexical-react packages/lexical-playground
 Test Files  58 passed (58)
      Tests  484 passed (484)

$ npx vitest run --project browser packages/lexical-react/src/__tests__/browser/useMenuAnchorRefPosition.test.tsx
 Test Files  1 passed (1)
      Tests  3 passed (3)

$ npx tsc --noEmit -p .
(clean)

Supersedes #8965, #8968, #9012, #9017, #9022, #9024, #9026, #9030, #9033, #9040,
consolidated per the review feedback on #9027 and #9035.

…ive when their inputs change

## Description

A React plugin or hook that reads editor state once, on the first render, and
then subscribes for future changes only reports a stale answer for everything
that happened before it subscribed — and never re-derives when the prop or
argument it was seeded from changes. `registerUpdateListener` and friends do not
fire on registration, an effect whose dependency list omits the prop that changed
never re-runs, and a value read off the editor at render time (`editor._editable`,
`editor.isEditable()`) is not a subscription at all. The same shape shows up on
the handler side: a command handler that claims the event when there is nothing
to act on, and a state update that clobbers the selection it was supposed to
leave alone.

Each of the fixes below re-derives (or resubscribes) from the input that actually
changed, and none of them changes behaviour when the inputs are stable.

- `CollaborationPlugin` (`packages/lexical-react/src/LexicalCollaborationPlugin.tsx`)
  guarded provider creation with an `isProviderInitialized` ref, so a changed
  `providerFactory`, `id` or `yjsDocMap` kept the old provider forever. The ref
  now records the inputs that produced the current provider, so a real change
  creates a new one while a StrictMode/remount re-run with unchanged inputs still
  does not.
- `useMenuAnchorRef` (`packages/lexical-react/src/shared/LexicalMenu.tsx`)
  positioned the absolutely-positioned anchor in document coordinates
  unconditionally. When a `parent` is passed it is normally positioned, so it —
  not the initial containing block — is the anchor's containing block, and the
  menu landed at the parent's own offset instead of at the caret. New
  `getContainingBlockOrigin()` resolves the real origin and the `toAnchorLeft`/
  `toAnchorTop` helpers translate every viewport coordinate through it, including
  the two flip-into-view branches. Fixes facebook#6989.
- `DraggableBlockPlugin_EXPERIMENTAL`
  (`packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx`) read
  `editor._editable` during render, which is not a subscription, so the handle
  did not appear or disappear on `setEditable()`. It now uses
  `useLexicalEditable()`.
- `LexicalMenu` (`packages/lexical-react/src/shared/LexicalMenu.tsx`) returned
  `true` from its `KEY_ARROW_DOWN_COMMAND`/`KEY_ARROW_UP_COMMAND` handlers even
  when `options` was empty — no menu is rendered in that case, so the arrow keys
  were swallowed and the caret stopped moving. Both handlers now return `false`
  when there is nothing to move through.
- `NodeContextMenuPlugin`
  (`packages/lexical-react/src/LexicalNodeContextMenuPlugin.tsx`) called
  `preventDefault()` and mounted its scroll-locking overlay before computing
  which items `$showOn` allows, so a node with no applicable items suppressed the
  browser's own context menu and showed an empty one. The filtering moved ahead
  of `preventDefault()` and the handler returns early when nothing is visible.
- `useLexicalIsTextContentEmpty`
  (`packages/lexical-react/src/useLexicalIsTextContentEmpty.ts`) seeded its state
  on the first render only, so a new `editor` or a new `trim` kept reporting the
  previous answer until the next update. The layout effect now re-derives the
  value before subscribing (and the initial `useState` is lazy, so it no longer
  reads the editor on every render).
- `useLexicalNodeSelection`
  (`packages/lexical-react/src/useLexicalNodeSelection.ts`) created an empty
  `NodeSelection` when asked to *de*select a node while the user held a
  `RangeSelection`, discarding their caret. Deselecting with no `NodeSelection`
  present is now a no-op.
- The playground's `TextFormatFloatingToolbar`
  (`packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx`)
  gated its buttons on `editor.isEditable()` read during render, so the toolbar
  kept its format buttons after `setEditable(false)`. It now uses
  `useLexicalEditable()`.
- `useCharacterLimit` (`packages/lexical-react/src/shared/useCharacterLimit.ts`)
  only ever counted inside `registerTextContentListener`, which does not fire on
  registration, so an editor that mounts with content reported the full budget
  remaining and left overflowing text unwrapped until the next keystroke. The
  body is extracted into `$updateCharacterLimit()` and called once before
  subscribing.
- `TreeView` (`packages/lexical-react/src/LexicalTreeView.tsx`) seeded
  `editorCurrentState` on the first render only and its effect had `editor` in
  the deps but never re-read the state, so a changed `editor` prop kept rendering
  the previous editor's tree. The effect re-reads `editor.getEditorState()`
  before subscribing, and is a `useLayoutEffect` to match
  `useCanShowPlaceholder`/`ContentEditableElement` and avoid a cascading render.

## Test plan

Nine new unit test files and one new browser test file, one per fix, each
asserting the stale value is re-derived (or that the handler declines to act);
`packages/lexical-react/src/__tests__/unit/LexicalCollaborationPlugin.test.tsx`
gains a case for the provider swap. Every test fails on `main` with only the test
files applied and passes with the fixes.

### Before

Source fixes reverted, new tests kept:

```
$ npx vitest run --project unit packages/lexical-react packages/lexical-playground
     × leaves the native context menu alone when no item is shown 275ms
     × keeps a range selection when asked to deselect 88ms
     × hides its format buttons when the editor becomes read-only 206ms
     × counts the text that is already in the editor when it mounts 95ms
     × reports a full budget for an editor that is under the limit 8ms
     × counts in the charset it was given 6ms
     × follows setEditable 143ms
     × renders no drag handle for an editor that mounts read-only 17ms
     × lets ArrowDown through when there are no options 63ms
     × lets ArrowUp through when there are no options 4ms
     × provider is replaced when providerFactory changes 7ms
     × follows a change of the trim argument 19ms
     × follows a change of the editor 5ms
     × re-renders when the editor prop changes 14ms
 Test Files  9 failed | 49 passed (58)
      Tests  14 failed | 470 passed (484)

$ npx vitest run --project browser packages/lexical-react/src/__tests__/browser/useMenuAnchorRefPosition.test.tsx
     × anchors the menu at the caret when parent is a positioned element 147ms
     × anchors the menu at the caret when the positioned parent is scrolled 99ms
 Test Files  1 failed (1)
      Tests  2 failed | 1 passed (3)
```

### After

```
$ npx vitest run --project unit packages/lexical-react packages/lexical-playground
 Test Files  58 passed (58)
      Tests  484 passed (484)

$ npx vitest run --project browser packages/lexical-react/src/__tests__/browser/useMenuAnchorRefPosition.test.tsx
 Test Files  1 passed (1)
      Tests  3 passed (3)

$ npx tsc --noEmit -p .
(clean)
```

Supersedes facebook#8965, facebook#8968, facebook#9012, facebook#9017, facebook#9022, facebook#9024, facebook#9026, facebook#9030, facebook#9033, facebook#9040,
consolidated per the review feedback on facebook#9027 and facebook#9035.
@vercel

vercel Bot commented Aug 10, 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.

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.

Bug: Menu Selection and Scrolling Misbehavior in Limited Lexical Editor Space

1 participant