From ba9372ffbe56e6ecb800d35756393449dd4d13a9 Mon Sep 17 00:00:00 2001 From: Safa Oueslati <92674307+safaweslati@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:03:37 +0100 Subject: [PATCH] fix(react): keep the suggestion menu reference while the menu does not move SuggestionMenuController and GridSuggestionMenuController built the popover reference with an inline selector, so it was a new object on every store update. GenericPopover then set its position reference again on each keystroke. With fast typing, React counted these queued updates as nested updates and threw error 185 after about 50 keys. The reference now changes only with the editor element and the values of referencePos. Co-Authored-By: Claude Opus 5.5 --- .../GridSuggestionMenuController.tsx | 31 +++-- ...uggestionMenuController.reference.test.tsx | 114 ++++++++++++++++++ .../SuggestionMenuController.tsx | 31 +++-- 3 files changed, 152 insertions(+), 24 deletions(-) create mode 100644 packages/react/src/components/SuggestionMenu/SuggestionMenuController.reference.test.tsx diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index 840178a50f..b500d34e96 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -111,18 +111,25 @@ export function GridSuggestionMenuController< }, [suggestionMenu, triggerCharacter, shouldOpen]); const state = useExtensionState(SuggestionMenu); - const reference = useExtensionState(SuggestionMenu, { - selector: (state) => - ({ - // Use first child as the editor DOM element may itself be scrollable. - // For FloatingUI to auto-update the position during scrolling, the - // `contextElement` must be a descendant of the scroll container. - element: (editorDOMElement?.firstChild || undefined) as - | Element - | undefined, - getBoundingClientRect: () => state?.referencePos || new DOMRect(), - }) satisfies GenericPopoverReference, - }); + // The suggestion menu plugin creates a new `referencePos` object on every + // update, also when the menu does not move. A new reference makes + // `GenericPopover` set the position reference again, which is one more React + // update for each keystroke. Fast typing then exceeds the React limit of 50 + // nested updates (error 185), so the reference changes only with the values. + const { x, y, width, height } = state?.referencePos ?? {}; + const reference = useMemo( + () => ({ + // Use first child as the editor DOM element may itself be scrollable. + // For FloatingUI to auto-update the position during scrolling, the + // `contextElement` must be a descendant of the scroll container. + element: (editorDOMElement?.firstChild || undefined) as + | Element + | undefined, + getBoundingClientRect: () => + x === undefined ? new DOMRect() : new DOMRect(x, y, width, height), + }), + [editorDOMElement, x, y, width, height], + ); const floatingUIOptions = useMemo( () => ({ diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.reference.test.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.reference.test.tsx new file mode 100644 index 0000000000..8a7cac4f96 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.reference.test.tsx @@ -0,0 +1,114 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { SuggestionMenu } from "@blocknote/core/extensions"; +import { act, type ReactElement, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { BlockNoteViewRaw } from "../../editor/BlockNoteView.js"; +import { GridSuggestionMenuController } from "./GridSuggestionMenu/GridSuggestionMenuController.js"; +import { SuggestionMenuController } from "./SuggestionMenuController.js"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const references = vi.hoisted(() => [] as unknown[]); + +vi.mock("../Popovers/GenericPopover.js", () => ({ + GenericPopover: (props: { reference?: unknown; children?: ReactNode }) => { + references.push(props.reference); + return null; + }, +})); + +let root: Root | undefined; +let host: HTMLElement | undefined; + +// jsdom returns a rect without `toJSON`. Like a browser, the stub returns a new +// object with the same values on each call. +function stubBoundingClientRect() { + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation( + () => { + const rect = new DOMRect(10, 20, 8, 16); + return Object.assign(rect, { + toJSON: () => ({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }), + }); + }, + ); +} + +async function render(element: ReactElement) { + stubBoundingClientRect(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + await act(async () => root!.render(element)); +} + +afterEach(() => { + act(() => root?.unmount()); + host?.remove(); + root = undefined; + host = undefined; + references.length = 0; + vi.restoreAllMocks(); +}); + +async function typeQueryAfterTrigger( + editor: BlockNoteEditor, + triggerCharacter: string, +) { + await act(async () => { + editor.getExtension(SuggestionMenu)!.openSuggestionMenu(triggerCharacter, { + deleteTriggerCharacter: true, + }); + }); + const referencesAtOpen = references.length; + for (const character of "query") { + await act(async () => { + editor.transact((tr) => tr.insertText(character)); + }); + } + return references.slice(referencesAtOpen); +} + +describe("suggestion menu reference", () => { + it("keeps the list menu reference while the menu does not move", async () => { + const editor = BlockNoteEditor.create(); + await render( + + + , + ); + + const referencesWhileTyping = await typeQueryAfterTrigger(editor, "/"); + + expect(referencesWhileTyping.length).toBeGreaterThan(1); + expect(new Set(referencesWhileTyping).size).toBe(1); + }); + + it("keeps the grid menu reference while the menu does not move", async () => { + const editor = BlockNoteEditor.create(); + await render( + + + , + ); + + const referencesWhileTyping = await typeQueryAfterTrigger(editor, ":"); + + expect(referencesWhileTyping.length).toBeGreaterThan(1); + expect(new Set(referencesWhileTyping).size).toBe(1); + }); +}); diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 6df8677288..971939b543 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -104,18 +104,25 @@ export function SuggestionMenuController< }, [suggestionMenu, triggerCharacter, shouldOpen]); const state = useExtensionState(SuggestionMenuExtension); - const reference = useExtensionState(SuggestionMenuExtension, { - selector: (state) => - ({ - // Use first child as the editor DOM element may itself be scrollable. - // For FloatingUI to auto-update the position during scrolling, the - // `contextElement` must be a descendant of the scroll container. - element: (editorDOMElement?.firstChild || undefined) as - | Element - | undefined, - getBoundingClientRect: () => state?.referencePos || new DOMRect(), - }) satisfies GenericPopoverReference, - }); + // The suggestion menu plugin creates a new `referencePos` object on every + // update, also when the menu does not move. A new reference makes + // `GenericPopover` set the position reference again, which is one more React + // update for each keystroke. Fast typing then exceeds the React limit of 50 + // nested updates (error 185), so the reference changes only with the values. + const { x, y, width, height } = state?.referencePos ?? {}; + const reference = useMemo( + () => ({ + // Use first child as the editor DOM element may itself be scrollable. + // For FloatingUI to auto-update the position during scrolling, the + // `contextElement` must be a descendant of the scroll container. + element: (editorDOMElement?.firstChild || undefined) as + | Element + | undefined, + getBoundingClientRect: () => + x === undefined ? new DOMRect() : new DOMRect(x, y, width, height), + }), + [editorDOMElement, x, y, width, height], + ); const floatingUIOptions = useMemo( () => ({