[lexical-react][lexical-playground] Bug Fix: plugins and hooks re-derive when their inputs change - #9051
Open
LeSingh1 wants to merge 1 commit into
Open
Conversation
…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.
LeSingh1
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
August 10, 2026 03:56
|
@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. |
This was referenced Aug 10, 2026
Closed
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
registerUpdateListenerand friends do notfire 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 onthe 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
isProviderInitializedref, so a changedproviderFactory,idoryjsDocMapkept the old provider forever. The refnow 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
parentis 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 thetoAnchorLeft/toAnchorTophelpers translate every viewport coordinate through it, includingthe 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) readeditor._editableduring render, which is not a subscription, so the handledid not appear or disappear on
setEditable(). It now usesuseLexicalEditable().LexicalMenu(packages/lexical-react/src/shared/LexicalMenu.tsx) returnedtruefrom itsKEY_ARROW_DOWN_COMMAND/KEY_ARROW_UP_COMMANDhandlers evenwhen
optionswas empty — no menu is rendered in that case, so the arrow keyswere swallowed and the caret stopped moving. Both handlers now return
falsewhen there is nothing to move through.
NodeContextMenuPlugin(
packages/lexical-react/src/LexicalNodeContextMenuPlugin.tsx) calledpreventDefault()and mounted its scroll-locking overlay before computingwhich items
$showOnallows, so a node with no applicable items suppressed thebrowser'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 stateon the first render only, so a new
editoror a newtrimkept reporting theprevious answer until the next update. The layout effect now re-derives the
value before subscribing (and the initial
useStateis lazy, so it no longerreads the editor on every render).
useLexicalNodeSelection(
packages/lexical-react/src/useLexicalNodeSelection.ts) created an emptyNodeSelectionwhen asked to deselect a node while the user held aRangeSelection, discarding their caret. Deselecting with noNodeSelectionpresent is now a no-op.
TextFormatFloatingToolbar(
packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx)gated its buttons on
editor.isEditable()read during render, so the toolbarkept its format buttons after
setEditable(false). It now usesuseLexicalEditable().useCharacterLimit(packages/lexical-react/src/shared/useCharacterLimit.ts)only ever counted inside
registerTextContentListener, which does not fire onregistration, 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 beforesubscribing.
TreeView(packages/lexical-react/src/LexicalTreeView.tsx) seedededitorCurrentStateon the first render only and its effect hadeditorinthe deps but never re-read the state, so a changed
editorprop kept renderingthe previous editor's tree. The effect re-reads
editor.getEditorState()before subscribing, and is a
useLayoutEffectto matchuseCanShowPlaceholder/ContentEditableElementand 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.tsxgains a case for the provider swap. Every test fails on
mainwith only the testfiles applied and passes with the fixes.
Before
Source fixes reverted, new tests kept:
After
Supersedes #8965, #8968, #9012, #9017, #9022, #9024, #9026, #9030, #9033, #9040,
consolidated per the review feedback on #9027 and #9035.