diff --git a/packages/mui-material/test/integration/MenuList.test.js b/packages/mui-material/test/integration/MenuList.test.js index 7bbaff63f38a75..c84dd750d9c3a3 100644 --- a/packages/mui-material/test/integration/MenuList.test.js +++ b/packages/mui-material/test/integration/MenuList.test.js @@ -445,6 +445,61 @@ describe(' integration', () => { expect(menu).toHaveFocus(); }); + it('should not infinite loop on keyboard navigation when there are no children', () => { + render(); + + const menu = screen.getByRole('menu'); + + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(menu).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'ArrowUp' }); + expect(menu).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'Home' }); + expect(menu).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'End' }); + expect(menu).toHaveFocus(); + }); + + it('should not infinite loop on keyboard navigation when children are removed', () => { + function DynamicMenuList() { + const [items, setItems] = React.useState(['Item 1', 'Item 2']); + + return ( + + + + {items.map((item) => ( + {item} + ))} + + + ); + } + + render(); + + const menu = screen.getByRole('menu'); + const menuitems = screen.getAllByRole('menuitem'); + + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(menuitems[0]).toHaveFocus(); + + // Remove all children + fireEvent.click(screen.getByTestId('clear')); + + act(() => { + menu.focus(); + }); + + // Should not hang + fireEvent.keyDown(menu, { key: 'ArrowDown' }); + expect(menu).toHaveFocus(); + fireEvent.keyDown(menu, { key: 'ArrowUp' }); + expect(menu).toHaveFocus(); + }); + it('should allow focus on disabled items when disabledItemsFocusable=true', () => { render( diff --git a/packages/mui-utils/src/useRovingTabIndex/useRovingTabIndex.test.tsx b/packages/mui-utils/src/useRovingTabIndex/useRovingTabIndex.test.tsx index bbc43917c76922..8cb1fbf1b5ae8f 100644 --- a/packages/mui-utils/src/useRovingTabIndex/useRovingTabIndex.test.tsx +++ b/packages/mui-utils/src/useRovingTabIndex/useRovingTabIndex.test.tsx @@ -154,6 +154,71 @@ describe('useRovingTabIndex', () => { expect(screen.getByTestId('container').getAttribute('tabindex')).to.equal('-1'); }); + test('should not infinite loop when focusNext is called with no children', () => { + let focusNextResult: number | undefined; + + function EmptyContainer() { + const { getContainerProps, focusNext: focusNextFn } = useRovingTabIndex({ + orientation: 'horizontal', + }); + + focusNext = focusNextFn; + + return
; + } + + render(); + + act(() => { + focusNextResult = focusNext(); + }); + + expect(focusNextResult).to.equal(-1); + }); + + test('should not infinite loop on arrow key navigation with no children', async () => { + function EmptyContainer() { + const { getContainerProps } = useRovingTabIndex({ + orientation: 'horizontal', + }); + + return
; + } + + const { user } = render(); + + const container = screen.getByTestId('container'); + container.focus(); + + // These would hang if the bug is present + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowLeft}'); + await user.keyboard('{Home}'); + await user.keyboard('{End}'); + + expect(container).toHaveFocus(); + }); + + test('should not infinite loop on arrow key navigation with no children (vertical)', async () => { + function EmptyContainer() { + const { getContainerProps } = useRovingTabIndex({ + orientation: 'vertical', + }); + + return
; + } + + const { user } = render(); + + const container = screen.getByTestId('container'); + container.focus(); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowUp}'); + + expect(container).toHaveFocus(); + }); + test('should make the controlled prop take precedence over internal state', async () => { const focusableIndex = 1; diff --git a/packages/mui-utils/src/useRovingTabIndex/useRovingTabIndex.ts b/packages/mui-utils/src/useRovingTabIndex/useRovingTabIndex.ts index bce6b1583e81b6..e6920356b08dae 100644 --- a/packages/mui-utils/src/useRovingTabIndex/useRovingTabIndex.ts +++ b/packages/mui-utils/src/useRovingTabIndex/useRovingTabIndex.ts @@ -214,6 +214,10 @@ function focusNext( wrap: boolean, shouldFocus: (element: HTMLElement | null) => boolean, ): number { + if (elementsRef.current.length === 0) { + return -1; + } + const lastIndex = elementsRef.current.length - 1; let wrappedOnce = false; let nextIndex = getNextIndex(currentIndex, lastIndex, direction, wrap);