From ca23bf2b7b4b6919fbd301fb817aba336cde6321 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 29 Oct 2026 18:25:59 -0400 Subject: [PATCH 1/2] fix(mosaic): keep press-opened hover menus from closing on pointer leave --- .../mosaic-menu-press-open-hover-race.md | 5 ++++ .../mosaic/src/primitives/menu/menu-root.tsx | 3 ++- .../mosaic/src/primitives/menu/menu.test.tsx | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 .changeset/mosaic-menu-press-open-hover-race.md 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..530bc92d088 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(); + renderHoverMenu(); + const trigger = screen.getByText('Actions'); + + try { + 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(); From e62519792aa980cfebe38393183843a44c0e1648 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 29 Oct 2026 18:44:56 -0400 Subject: [PATCH 2/2] test(mosaic): restore real timers if hover menu test setup throws --- packages/mosaic/src/primitives/menu/menu.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/primitives/menu/menu.test.tsx b/packages/mosaic/src/primitives/menu/menu.test.tsx index 530bc92d088..e324aefa9e5 100644 --- a/packages/mosaic/src/primitives/menu/menu.test.tsx +++ b/packages/mosaic/src/primitives/menu/menu.test.tsx @@ -960,10 +960,10 @@ describe('Menu', () => { it('stays open when the mouse leaves a menu pressed open just before the hover delay ran out', () => { vi.useFakeTimers(); - renderHoverMenu(); - const trigger = screen.getByText('Actions'); - try { + renderHoverMenu(); + const trigger = screen.getByText('Actions'); + fireEvent.pointerEnter(trigger, { pointerType: 'mouse' }); fireEvent.mouseEnter(trigger); fireEvent.pointerDown(trigger, { pointerType: 'mouse' });