From 91f4db8d4fcd6dc46c204c98c65aed8eac7e409c Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Sun, 9 Aug 2026 20:50:41 -0700 Subject: [PATCH] [lexical-react][lexical-playground] Bug Fix: plugins and hooks re-derive when their inputs change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 #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 #8965, #8968, #9012, #9017, #9022, #9024, #9026, #9030, #9033, #9040, consolidated per the review feedback on #9027 and #9035. --- .../unit/FloatingTextFormatToolbar.test.tsx | 111 +++++++++++ .../FloatingTextFormatToolbarPlugin/index.tsx | 6 +- .../src/LexicalCollaborationPlugin.tsx | 20 +- .../src/LexicalDraggableBlockPlugin.tsx | 7 +- .../src/LexicalNodeContextMenuPlugin.tsx | 33 ++-- .../lexical-react/src/LexicalTreeView.tsx | 11 +- .../browser/useMenuAnchorRefPosition.test.tsx | 131 +++++++++++++ .../unit/LexicalCharacterLimitPlugin.test.tsx | 119 ++++++++++++ .../unit/LexicalCollaborationPlugin.test.tsx | 88 +++++++++ .../unit/LexicalDraggableBlockPlugin.test.tsx | 137 ++++++++++++++ .../unit/LexicalMenuEmptyOptions.test.tsx | 147 +++++++++++++++ .../LexicalNodeContextMenuPlugin.test.tsx | 99 ++++++++++ .../__tests__/unit/LexicalTreeView.test.tsx | 82 ++++++++ ...seLexicalIsTextContentEmptyResync.test.tsx | 97 ++++++++++ .../unit/useLexicalNodeSelection.test.tsx | 162 ++++++++++++++++ .../lexical-react/src/shared/LexicalMenu.tsx | 177 +++++++++++------- .../src/shared/useCharacterLimit.ts | 56 +++--- .../src/useLexicalIsTextContentEmpty.ts | 14 +- .../src/useLexicalNodeSelection.ts | 7 + 19 files changed, 1393 insertions(+), 111 deletions(-) create mode 100644 packages/lexical-playground/__tests__/unit/FloatingTextFormatToolbar.test.tsx create mode 100644 packages/lexical-react/src/__tests__/browser/useMenuAnchorRefPosition.test.tsx create mode 100644 packages/lexical-react/src/__tests__/unit/LexicalCharacterLimitPlugin.test.tsx create mode 100644 packages/lexical-react/src/__tests__/unit/LexicalDraggableBlockPlugin.test.tsx create mode 100644 packages/lexical-react/src/__tests__/unit/LexicalMenuEmptyOptions.test.tsx create mode 100644 packages/lexical-react/src/__tests__/unit/LexicalNodeContextMenuPlugin.test.tsx create mode 100644 packages/lexical-react/src/__tests__/unit/LexicalTreeView.test.tsx create mode 100644 packages/lexical-react/src/__tests__/unit/useLexicalIsTextContentEmptyResync.test.tsx create mode 100644 packages/lexical-react/src/__tests__/unit/useLexicalNodeSelection.test.tsx diff --git a/packages/lexical-playground/__tests__/unit/FloatingTextFormatToolbar.test.tsx b/packages/lexical-playground/__tests__/unit/FloatingTextFormatToolbar.test.tsx new file mode 100644 index 00000000000..ed61d8e6e9f --- /dev/null +++ b/packages/lexical-playground/__tests__/unit/FloatingTextFormatToolbar.test.tsx @@ -0,0 +1,111 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {RovingTabIndexExtension} from '@lexical/a11y'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $selectAll, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import * as React from 'react'; +import {act} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import FloatingTextFormatToolbarPlugin from '../../src/plugins/FloatingTextFormatToolbarPlugin'; + +const ToolbarTestExtension = /* @__PURE__ */ defineExtension({ + $initialEditorState: () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('hello'))); + }, + dependencies: [RichTextExtension, RovingTabIndexExtension], + name: '[test-floating-toolbar]', +}); + +describe('FloatingTextFormatToolbarPlugin', () => { + let container: HTMLDivElement; + let anchorElem: HTMLDivElement; + let reactRoot: Root; + let editor: LexicalEditor; + + function Capture() { + const [contextEditor] = useLexicalComposerContext(); + editor = contextEditor; + return null; + } + + beforeEach(async () => { + container = document.createElement('div'); + anchorElem = document.createElement('div'); + document.body.append(container, anchorElem); + reactRoot = createRoot(container); + + await act(async () => { + reactRoot.render( + + + + , + ); + }); + + // The popup only opens for a non-collapsed selection whose DOM anchor is + // inside the root element, so set both. + await act(async () => { + editor.update(() => void $selectAll(), {discrete: true}); + const textDOM = editor.getRootElement()!.querySelector('p')!.firstChild! + .firstChild!; + document + .getSelection()! + .setBaseAndExtent(textDOM, 0, textDOM, 'hello'.length); + document.dispatchEvent(new Event('selectionchange')); + }); + }); + + afterEach(async () => { + await act(async () => { + reactRoot.unmount(); + }); + container.remove(); + anchorElem.remove(); + }); + + function formatButtonCount(): number { + return anchorElem.querySelectorAll( + '.floating-text-format-popup button[aria-label^="Format text"]', + ).length; + } + + it('hides its format buttons when the editor becomes read-only', async () => { + expect(anchorElem.querySelector('.floating-text-format-popup')).not.toBe( + null, + ); + expect(formatButtonCount()).toBeGreaterThan(0); + + await act(async () => { + editor.setEditable(false); + }); + expect(formatButtonCount()).toBe(0); + + await act(async () => { + editor.setEditable(true); + }); + expect(formatButtonCount()).toBeGreaterThan(0); + }); +}); diff --git a/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx index 88d56ebc83e..ccd4f0bcba7 100644 --- a/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx @@ -12,6 +12,7 @@ import {useMergeRefs} from '@floating-ui/react'; import {$isCodeNode} from '@lexical/code'; import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import {useLexicalRovingTabIndexRef} from '@lexical/react/useLexicalRovingTabIndexRef'; import { $getSelection, @@ -82,6 +83,9 @@ function TextFormatFloatingToolbar({ }): JSX.Element { const popupCharStylesEditorRef = useRef(null); const rovingRef = useLexicalRovingTabIndexRef(); + // Subscribed rather than read off the editor, so that setEditable() actually + // re-renders the toolbar. + const isEditable = useLexicalEditable(); const mergedRef = useMergeRefs([popupCharStylesEditorRef, rovingRef, ref]); const insertLink = useCallback(() => { @@ -224,7 +228,7 @@ function TextFormatFloatingToolbar({ className="floating-text-format-popup" role="toolbar" aria-label="Floating text format toolbar"> - {editor.isEditable() && ( + {isEditable && ( <>