From 583b3eb522d462d10ae9548924022ebe1cb0cef8 Mon Sep 17 00:00:00 2001 From: uditDewan Date: Wed, 14 Oct 2026 17:46:41 -0400 Subject: [PATCH 1/2] fix(menu): don't show focus ring on hover-initiated MenuItem focus On hover, onHoverIn hides the focus ring and useHoverFocusEffect then focuses the item. onFocus decided whether the focus came from hover by reading pressable.state.focused, which is stale at that point, so it always re-enabled the focus ring. Track hover-initiated focus with a ref instead so onFocus sees it synchronously. Fixes #4083 --- .changeset/menuitem-hover-focus-ring.md | 5 ++ .../src/MenuItem/useMenuItem.test.win32.tsx | 51 +++++++++++++++++++ .../Menu/src/MenuItem/useMenuItem.ts | 8 ++- 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 .changeset/menuitem-hover-focus-ring.md create mode 100644 packages/components/Menu/src/MenuItem/useMenuItem.test.win32.tsx diff --git a/.changeset/menuitem-hover-focus-ring.md b/.changeset/menuitem-hover-focus-ring.md new file mode 100644 index 00000000000..a9483cf1fed --- /dev/null +++ b/.changeset/menuitem-hover-focus-ring.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/menu': patch +--- + +Fix `MenuItem` showing a focus ring when focus moves to it from mouse hover on win32. The hover-initiated focus is now tracked with a ref instead of stale pressable state. diff --git a/packages/components/Menu/src/MenuItem/useMenuItem.test.win32.tsx b/packages/components/Menu/src/MenuItem/useMenuItem.test.win32.tsx new file mode 100644 index 00000000000..6fcc84cfe92 --- /dev/null +++ b/packages/components/Menu/src/MenuItem/useMenuItem.test.win32.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { act } from 'react'; +import * as renderer from 'react-test-renderer'; + +import type { MenuItemInfo } from './MenuItem.types'; +import { useMenuItem } from './useMenuItem'; + +describe('useMenuItem focus ring (win32)', () => { + let info: MenuItemInfo; + const Probe = () => { + info = useMenuItem({ componentRef: React.useRef({ focus: jest.fn(), blur: jest.fn() } as any) }); + return null; + }; + + beforeEach(() => { + act(() => { + renderer.create(); + }); + }); + + it('keeps the focus ring hidden when hover moves focus before React re-renders', () => { + // Simulate the native focus event firing with the handlers from the same render as onHoverIn. + const { onHoverIn, onFocus } = info.props; + act(() => { + onHoverIn({} as any); + onFocus({} as any); + }); + expect(info.props.enableFocusRing).toBe(false); + }); + + it('shows the focus ring for a keyboard focus after a hover focus', () => { + act(() => { + info.props.onHoverIn({} as any); + }); + act(() => { + info.props.onFocus({} as any); + }); + expect(info.props.enableFocusRing).toBe(false); + + act(() => { + info.props.onBlur({} as any); + }); + act(() => { + info.props.onHoverOut({} as any); + }); + act(() => { + info.props.onFocus({} as any); + }); + expect(info.props.enableFocusRing).toBe(true); + }); +}); diff --git a/packages/components/Menu/src/MenuItem/useMenuItem.ts b/packages/components/Menu/src/MenuItem/useMenuItem.ts index 7364567e474..20d3f6f9d98 100644 --- a/packages/components/Menu/src/MenuItem/useMenuItem.ts +++ b/packages/components/Menu/src/MenuItem/useMenuItem.ts @@ -68,12 +68,16 @@ export const useMenuItem = (props: MenuItemProps): MenuItemInfo => { useHoverFocusEffect(pressable.state.hovered, componentRef); const [enableFocusRing, setEnableFocusRing] = React.useState(!pressable.state.hovered); + // Tracked in a ref rather than state: the focus caused by hovering can fire before React re-renders, so state read in + // onFocus would still be the pre-hover value. + const isHoverFocus = React.useRef(false); const onHoverIn = React.useCallback( (e) => { pressable.props.onHoverIn(e); // when it's a hover focus, set enableFocusRing explicitly to false if (!pressable.state.focused) { + isHoverFocus.current = true; setEnableFocusRing(false); } }, @@ -84,7 +88,9 @@ export const useMenuItem = (props: MenuItemProps): MenuItemInfo => { (e) => { pressable.props.onFocus(e); // when it's not a hover focus, set enableFocusRing explicitly to true - if (!pressable.state.focused) { + if (isHoverFocus.current) { + isHoverFocus.current = false; + } else { setEnableFocusRing(true); } }, From 6c7402480e9ed4e45bf46d6704dbeed7c062c78f Mon Sep 17 00:00:00 2001 From: uditDewan Date: Fri, 30 Oct 2026 22:08:46 -0400 Subject: [PATCH 2/2] fix(menu): clear hover focus flag when hover ends --- .../Menu/src/MenuItem/useMenuItem.test.win32.tsx | 13 +++++++++++++ .../components/Menu/src/MenuItem/useMenuItem.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/components/Menu/src/MenuItem/useMenuItem.test.win32.tsx b/packages/components/Menu/src/MenuItem/useMenuItem.test.win32.tsx index 6fcc84cfe92..71bf14d3be3 100644 --- a/packages/components/Menu/src/MenuItem/useMenuItem.test.win32.tsx +++ b/packages/components/Menu/src/MenuItem/useMenuItem.test.win32.tsx @@ -48,4 +48,17 @@ describe('useMenuItem focus ring (win32)', () => { }); expect(info.props.enableFocusRing).toBe(true); }); + + it('shows the focus ring for a keyboard focus after a hover that never focused the item', () => { + // Hover in and out before the hover focus lands, so no focus event comes from the hover. + const { onHoverIn, onHoverOut } = info.props; + act(() => { + onHoverIn({} as any); + onHoverOut({} as any); + }); + act(() => { + info.props.onFocus({} as any); + }); + expect(info.props.enableFocusRing).toBe(true); + }); }); diff --git a/packages/components/Menu/src/MenuItem/useMenuItem.ts b/packages/components/Menu/src/MenuItem/useMenuItem.ts index 20d3f6f9d98..990353852cd 100644 --- a/packages/components/Menu/src/MenuItem/useMenuItem.ts +++ b/packages/components/Menu/src/MenuItem/useMenuItem.ts @@ -84,6 +84,15 @@ export const useMenuItem = (props: MenuItemProps): MenuItemInfo => { [pressable], ); + const onHoverOut = React.useCallback( + (e) => { + pressable.props.onHoverOut(e); + // the hover ended, so any focus the hover asked for may never arrive; don't let it eat the next real focus + isHoverFocus.current = false; + }, + [pressable], + ); + const onFocus = React.useCallback( (e) => { pressable.props.onFocus(e); @@ -107,6 +116,10 @@ export const useMenuItem = (props: MenuItemProps): MenuItemInfo => { macos: pressable.props.onHoverIn, default: onHoverIn, // win32 }), + onHoverOut: Platform.select({ + macos: pressable.props.onHoverOut, + default: onHoverOut, // win32 + }), onFocus: Platform.select({ macos: pressable.props.onFocus, default: onFocus, // win32