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( () => ({