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 1/2] 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( () => ({ From 04e779c2b83c0dd43796d978ed75e1f013335da4 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 29 Oct 2026 15:34:05 +0100 Subject: [PATCH 2/2] fix(react): stabilize suggestion references with selector equality --- .../GridSuggestionMenuController.tsx | 43 +++++++++++-------- .../SuggestionMenuController.tsx | 43 +++++++++++-------- packages/react/src/hooks/useExtension.ts | 8 +++- packages/react/src/hooks/useStore.ts | 5 ++- 4 files changed, 58 insertions(+), 41 deletions(-) diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index b500d34e96..0429cb8a7c 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -111,25 +111,30 @@ export function GridSuggestionMenuController< }, [suggestionMenu, triggerCharacter, shouldOpen]); const state = useExtensionState(SuggestionMenu); - // 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 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, + equalityFn: (a, b) => { + // Ignore fresh rectangle/callback identities when the anchor has not moved. + const aRect = a.getBoundingClientRect(); + const bRect = b.getBoundingClientRect(); + return ( + a.element === b.element && + aRect.x === bRect.x && + aRect.y === bRect.y && + aRect.width === bRect.width && + aRect.height === bRect.height + ); + }, + }); const floatingUIOptions = useMemo( () => ({ diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 971939b543..0f226e2334 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -104,25 +104,30 @@ export function SuggestionMenuController< }, [suggestionMenu, triggerCharacter, shouldOpen]); const state = useExtensionState(SuggestionMenuExtension); - // 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 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, + equalityFn: (a, b) => { + // Ignore fresh rectangle/callback identities when the anchor has not moved. + const aRect = a.getBoundingClientRect(); + const bRect = b.getBoundingClientRect(); + return ( + a.element === b.element && + aRect.x === bRect.x && + aRect.y === bRect.y && + aRect.width === bRect.width && + aRect.height === bRect.height + ); + }, + }); const floatingUIOptions = useMemo( () => ({ diff --git a/packages/react/src/hooks/useExtension.ts b/packages/react/src/hooks/useExtension.ts index 0323feda8c..23b41539ce 100644 --- a/packages/react/src/hooks/useExtension.ts +++ b/packages/react/src/hooks/useExtension.ts @@ -51,6 +51,8 @@ export function useExtensionState< ctx?: { editor?: BlockNoteEditor; selector?: (state: NoInfer>) => TSelected; + /** Custom selector equality check. Defaults to a shallow comparison. */ + equalityFn?: (a: NoInfer, b: NoInfer) => boolean; }, ): TSelected { const extension = useExtension( @@ -61,5 +63,9 @@ export function useExtensionState< if (!store) { throw new Error("Store not found on plugin", { cause: { plugin } }); } - return useStore, TSelected>(store, ctx?.selector as any); + return useStore, TSelected>( + store, + ctx?.selector, + ctx?.equalityFn, + ); } diff --git a/packages/react/src/hooks/useStore.ts b/packages/react/src/hooks/useStore.ts index 394836a440..1448cce703 100644 --- a/packages/react/src/hooks/useStore.ts +++ b/packages/react/src/hooks/useStore.ts @@ -12,19 +12,20 @@ import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/w * Subscribe to a {@link Store}, optionally selecting a slice of its state. * * The component re-renders only when the selected value changes under a - * {@link shallow} comparison, so selectors are free to build a fresh object, + * comparison (defaults to {@link shallow}), so selectors are free to build a fresh object, * `Map` or `Set` on each call. */ export function useStore>( store: Store, selector: (state: TState) => TSelected = (d) => d as unknown as TSelected, + equalityFn: (a: TSelected, b: TSelected) => boolean = shallow, ): TSelected { return useSyncExternalStoreWithSelector( store.subscribe, () => store.state, () => store.state, selector, - shallow, + equalityFn, ); }