diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index 840178a50f..0429cb8a7c 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -122,6 +122,18 @@ export function GridSuggestionMenuController< | 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.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..0f226e2334 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -115,6 +115,18 @@ export function SuggestionMenuController< | 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, ); }