diff --git a/.changeset/mosaic-menu-press-open-hover-race.md b/.changeset/mosaic-menu-press-open-hover-race.md new file mode 100644 index 00000000000..0142bc157fe --- /dev/null +++ b/.changeset/mosaic-menu-press-open-hover-race.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Fix hover-enabled menus, such as the `UserButton` account and settings flyouts, sometimes closing when the pointer moved toward an item after the menu was opened with a click. diff --git a/packages/mosaic/src/primitives/menu/menu-root.tsx b/packages/mosaic/src/primitives/menu/menu-root.tsx index 621e57ce993..a139c697411 100644 --- a/packages/mosaic/src/primitives/menu/menu-root.tsx +++ b/packages/mosaic/src/primitives/menu/menu-root.tsx @@ -24,6 +24,7 @@ import { useTypeahead, } from '@floating-ui/react'; import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; import { useDirection } from '../direction-provider'; import { useControllableState } from '../hooks/use-controllable-state'; @@ -156,7 +157,7 @@ function MenuInner(props: MenuProps) { const nativeEvent = event.nativeEvent; openFrameRef.current = requestAnimationFrame(() => { openFrameRef.current = null; - floatingContext.onOpenChange(true, nativeEvent, 'click'); + flushSync(() => floatingContext.onOpenChange(true, nativeEvent, 'click')); }); }, }, diff --git a/packages/mosaic/src/primitives/menu/menu.test.tsx b/packages/mosaic/src/primitives/menu/menu.test.tsx index f745ddea1da..e324aefa9e5 100644 --- a/packages/mosaic/src/primitives/menu/menu.test.tsx +++ b/packages/mosaic/src/primitives/menu/menu.test.tsx @@ -958,6 +958,33 @@ describe('Menu', () => { expect(screen.getByText('Actions')).toHaveAttribute('data-open', ''); }); + it('stays open when the mouse leaves a menu pressed open just before the hover delay ran out', () => { + vi.useFakeTimers(); + try { + renderHoverMenu(); + const trigger = screen.getByText('Actions'); + + fireEvent.pointerEnter(trigger, { pointerType: 'mouse' }); + fireEvent.mouseEnter(trigger); + fireEvent.pointerDown(trigger, { pointerType: 'mouse' }); + fireEvent.mouseDown(trigger, { button: 0 }); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(trigger).toHaveAttribute('data-open', ''); + + fireEvent.pointerLeave(trigger, { pointerType: 'mouse' }); + fireEvent.mouseLeave(trigger, { relatedTarget: screen.getByText('Elsewhere') }); + act(() => { + vi.runAllTimers(); + }); + + expect(trigger).toHaveAttribute('data-open', ''); + } finally { + vi.useRealTimers(); + } + }); + it('closes on a second press of the trigger', async () => { const user = userEvent.setup(); renderHoverMenu();