From a293af97885a2a9a0530a313635c7c324c044305 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 10 Apr 2025 22:27:11 +0200 Subject: [PATCH] Improve `Menu` component performance (#3685) This PR improves the performance of the `Menu` component. Before this PR, the `Menu` component is built in a way where all the state lives in the `Menu` itself. If state changes, everything re-renders and re-computes the necessary derived state. However, if you have a 1000 items, then every time the active item changes, all 1000 items have to re-render. To solve this, we can move the state outside of the `Menu` component, and "subscribe" to state changes using the `useSlice` hook introduced in https://github.com/tailwindlabs/headlessui/pull/3684. This will allow us to subscribe to a slice of the state, and only re-render if the computed slice actually changes. If the active item changes, only 3 things will happen: 1. The `MenuItems` will re-render and have an updated `aria-activedescendant` 2. The `MenuItem` that _was_ active, will re-render and the `data-focus` attribute wil be removed. 3. The `MenuItem` that is now active, will re-render and the `data-focus` attribute wil be added. Another improvement is that in order to make sure that your arrow keys go to the correct item, we need to sort the DOM nodes and make sure that we go to the correct item when using arrow up and down. This sorting was happening every time a new `MenuItem` was registered. Luckily, once an array is sorted, you don't have to do a lot, but you still have to loop over `n` items which is not ideal. This PR will now delay the sorting until all `MenuItem`s are registered. On that note, we also batch the `RegisterItem` so we can perform a single update instead of `n` updates. We use a microTask for the batching (so if you only are registering a single item, you don't have to wait compared to a `setTimeout` or a `requestAnimationFrame`). ## Test plan 1. All tests still pass 2. Tested this in the browser with a 1000 items. In the videos below the only thing I'm doing is holding down the `ArrowDown` key. Before: https://github.com/user-attachments/assets/513b02c1-fc69-47f3-a97e-c56d44dd585a After: https://github.com/user-attachments/assets/266236a0-b64a-4322-9a54-ead7fb62191f --- packages/@headlessui-react/CHANGELOG.md | 4 +- .../src/components/menu/menu-machine-glue.tsx | 17 + .../src/components/menu/menu-machine.ts | 385 +++++++++++++ .../src/components/menu/menu.tsx | 510 ++++-------------- 4 files changed, 515 insertions(+), 401 deletions(-) create mode 100644 packages/@headlessui-react/src/components/menu/menu-machine-glue.tsx create mode 100644 packages/@headlessui-react/src/components/menu/menu-machine.ts diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index d790b8a..f0054d3 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Improve `Menu` component performance ([#3685](https://github.com/tailwindlabs/headlessui/pull/3685)) ## [2.2.1] - 2025-04-04 diff --git a/packages/@headlessui-react/src/components/menu/menu-machine-glue.tsx b/packages/@headlessui-react/src/components/menu/menu-machine-glue.tsx new file mode 100644 index 0000000..324d3db --- /dev/null +++ b/packages/@headlessui-react/src/components/menu/menu-machine-glue.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext, useMemo } from 'react' +import { MenuMachine } from './menu-machine' + +export const MenuContext = createContext(null) +export function useMenuMachineContext(component: string) { + let context = useContext(MenuContext) + if (context === null) { + let err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useMenuMachine) + throw err + } + return context +} + +export function useMenuMachine({ __demoMode = false } = {}) { + return useMemo(() => MenuMachine.new({ __demoMode }), []) +} diff --git a/packages/@headlessui-react/src/components/menu/menu-machine.ts b/packages/@headlessui-react/src/components/menu/menu-machine.ts new file mode 100644 index 0000000..7177187 --- /dev/null +++ b/packages/@headlessui-react/src/components/menu/menu-machine.ts @@ -0,0 +1,385 @@ +import { Machine, batch } from '../../machine' +import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' +import { sortByDomNode } from '../../utils/focus-management' +import { match } from '../../utils/match' + +export enum MenuState { + Open, + Closed, +} + +export enum ActivationTrigger { + Pointer, + Other, +} + +export type MenuItemDataRef = { + current: { + textValue?: string + disabled: boolean + domRef: { current: HTMLElement | null } + } +} + +export interface State { + __demoMode: boolean + menuState: MenuState + + buttonElement: HTMLButtonElement | null + itemsElement: HTMLElement | null + + items: { id: string; dataRef: MenuItemDataRef }[] + searchQuery: string + activeItemIndex: number | null + activationTrigger: ActivationTrigger + + pendingShouldSort: boolean + pendingFocus: { focus: Exclude } | { focus: Focus.Specific; id: string } +} + +export enum ActionTypes { + OpenMenu, + CloseMenu, + + GoToItem, + Search, + ClearSearch, + RegisterItems, + UnregisterItem, + + SetButtonElement, + SetItemsElement, + + SortItems, +} + +function adjustOrderedState( + state: State, + adjustment: (items: State['items']) => State['items'] = (i) => i +) { + let currentActiveItem = state.activeItemIndex !== null ? state.items[state.activeItemIndex] : null + + let sortedItems = sortByDomNode( + adjustment(state.items.slice()), + (item) => item.dataRef.current.domRef.current + ) + + // If we inserted an item before the current active item then the active item index + // would be wrong. To fix this, we will re-lookup the correct index. + let adjustedActiveItemIndex = currentActiveItem ? sortedItems.indexOf(currentActiveItem) : null + + // Reset to `null` in case the currentActiveItem was removed. + if (adjustedActiveItemIndex === -1) { + adjustedActiveItemIndex = null + } + + return { + items: sortedItems, + activeItemIndex: adjustedActiveItemIndex, + } +} + +export type Actions = + | { type: ActionTypes.CloseMenu } + | { + type: ActionTypes.OpenMenu + focus: { focus: Exclude } | { focus: Focus.Specific; id: string } + trigger?: ActivationTrigger + } + | { type: ActionTypes.GoToItem; focus: Focus.Specific; id: string; trigger?: ActivationTrigger } + | { + type: ActionTypes.GoToItem + focus: Exclude + trigger?: ActivationTrigger + } + | { type: ActionTypes.Search; value: string } + | { type: ActionTypes.ClearSearch } + | { type: ActionTypes.RegisterItems; items: { id: string; dataRef: MenuItemDataRef }[] } + | { type: ActionTypes.UnregisterItem; id: string } + | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } + | { type: ActionTypes.SetItemsElement; element: HTMLElement | null } + | { type: ActionTypes.SortItems } + +let reducers: { + [P in ActionTypes]: (state: State, action: Extract) => State +} = { + [ActionTypes.CloseMenu](state) { + if (state.menuState === MenuState.Closed) return state + return { + ...state, + activeItemIndex: null, + pendingFocus: { focus: Focus.Nothing }, + menuState: MenuState.Closed, + } + }, + [ActionTypes.OpenMenu](state, action) { + if (state.menuState === MenuState.Open) return state + return { + ...state, + /* We can turn off demo mode once we re-open the `Menu` */ + __demoMode: false, + pendingFocus: action.focus, + menuState: MenuState.Open, + } + }, + [ActionTypes.GoToItem]: (state, action) => { + if (state.menuState === MenuState.Closed) return state + + let base = { + ...state, + searchQuery: '', + activationTrigger: action.trigger ?? ActivationTrigger.Other, + __demoMode: false, + } + + // Optimization: + // + // There is no need to sort the DOM nodes if we know that we don't want to focus anything + if (action.focus === Focus.Nothing) { + return { + ...base, + activeItemIndex: null, + } + } + + // Optimization: + // + // There is no need to sort the DOM nodes if we know exactly where to go + if (action.focus === Focus.Specific) { + return { + ...base, + activeItemIndex: state.items.findIndex((o) => o.id === action.id), + } + } + + // Optimization: + // + // If the current DOM node and the previous DOM node are next to each other, + // or if the previous DOM node is already the first DOM node, then we don't + // have to sort all the DOM nodes. + else if (action.focus === Focus.Previous) { + let activeItemIdx = state.activeItemIndex + if (activeItemIdx !== null) { + let currentDom = state.items[activeItemIdx].dataRef.current.domRef + let previousItemIndex = calculateActiveIndex(action, { + resolveItems: () => state.items, + resolveActiveIndex: () => state.activeItemIndex, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.current.disabled, + }) + if (previousItemIndex !== null) { + let previousDom = state.items[previousItemIndex].dataRef.current.domRef + if ( + // Next to each other + currentDom.current?.previousElementSibling === previousDom.current || + // Or already the first element + previousDom.current?.previousElementSibling === null + ) { + return { + ...base, + activeItemIndex: previousItemIndex, + } + } + } + } + } + + // Optimization: + // + // If the current DOM node and the next DOM node are next to each other, or + // if the next DOM node is already the last DOM node, then we don't have to + // sort all the DOM nodes. + else if (action.focus === Focus.Next) { + let activeItemIdx = state.activeItemIndex + if (activeItemIdx !== null) { + let currentDom = state.items[activeItemIdx].dataRef.current.domRef + let nextItemIndex = calculateActiveIndex(action, { + resolveItems: () => state.items, + resolveActiveIndex: () => state.activeItemIndex, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.current.disabled, + }) + if (nextItemIndex !== null) { + let nextDom = state.items[nextItemIndex].dataRef.current.domRef + if ( + // Next to each other + currentDom.current?.nextElementSibling === nextDom.current || + // Or already the last element + nextDom.current?.nextElementSibling === null + ) { + return { + ...base, + activeItemIndex: nextItemIndex, + } + } + } + } + } + + // Slow path: + // + // Ensure all the items are correctly sorted according to DOM position + let adjustedState = adjustOrderedState(state) + let activeItemIndex = calculateActiveIndex(action, { + resolveItems: () => adjustedState.items, + resolveActiveIndex: () => adjustedState.activeItemIndex, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.current.disabled, + }) + + return { + ...base, + ...adjustedState, + activeItemIndex, + } + }, + [ActionTypes.Search]: (state, action) => { + let wasAlreadySearching = state.searchQuery !== '' + let offset = wasAlreadySearching ? 0 : 1 + let searchQuery = state.searchQuery + action.value.toLowerCase() + + let reOrderedItems = + state.activeItemIndex !== null + ? state.items + .slice(state.activeItemIndex + offset) + .concat(state.items.slice(0, state.activeItemIndex + offset)) + : state.items + + let matchingItem = reOrderedItems.find( + (item) => + item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled + ) + + let matchIdx = matchingItem ? state.items.indexOf(matchingItem) : -1 + if (matchIdx === -1 || matchIdx === state.activeItemIndex) return { ...state, searchQuery } + return { + ...state, + searchQuery, + activeItemIndex: matchIdx, + activationTrigger: ActivationTrigger.Other, + } + }, + [ActionTypes.ClearSearch](state) { + if (state.searchQuery === '') return state + return { ...state, searchQuery: '', searchActiveItemIndex: null } + }, + [ActionTypes.RegisterItems]: (state, action) => { + let items = state.items.concat(action.items.map((item) => item)) + + let activeItemIndex = state.activeItemIndex + if (state.pendingFocus.focus !== Focus.Nothing) { + activeItemIndex = calculateActiveIndex(state.pendingFocus, { + resolveItems: () => items, + resolveActiveIndex: () => state.activeItemIndex, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.current.disabled, + }) + } + return { + ...state, + items, + activeItemIndex, + pendingFocus: { focus: Focus.Nothing }, + pendingShouldSort: true, + } + }, + [ActionTypes.UnregisterItem]: (state, action) => { + let items = state.items + let idx = items.findIndex((a) => a.id === action.id) + if (idx !== -1) { + items = items.slice() + items.splice(idx, 1) + } + + return { + ...state, + items, + activationTrigger: ActivationTrigger.Other, + } + }, + [ActionTypes.SetButtonElement]: (state, action) => { + if (state.buttonElement === action.element) return state + return { ...state, buttonElement: action.element } + }, + [ActionTypes.SetItemsElement]: (state, action) => { + if (state.itemsElement === action.element) return state + return { ...state, itemsElement: action.element } + }, + [ActionTypes.SortItems]: (state) => { + if (!state.pendingShouldSort) return state + + return { + ...state, + ...adjustOrderedState(state), + pendingShouldSort: false, + } + }, +} + +export class MenuMachine extends Machine { + static new({ __demoMode = false } = {}) { + return new MenuMachine({ + __demoMode, + menuState: __demoMode ? MenuState.Open : MenuState.Closed, + buttonElement: null, + itemsElement: null, + items: [], + searchQuery: '', + activeItemIndex: null, + activationTrigger: ActivationTrigger.Other, + pendingShouldSort: false, + pendingFocus: { focus: Focus.Nothing }, + }) + } + + constructor(initialState: State) { + super(initialState) + + this.on(ActionTypes.RegisterItems, () => { + // Schedule a sort of the items when the DOM is ready. This doesn't + // change anything rendering wise, but the sorted items are used when + // using arrow keys so we can jump to previous / next items. + requestAnimationFrame(() => { + this.send({ type: ActionTypes.SortItems }) + }) + }) + } + + reduce(state: Readonly, action: Actions): State { + return match(action.type, reducers, state, action) + } + + actions = { + // Batched version to register multiple items at the same time + registerItem: batch(() => { + let items: { id: string; dataRef: MenuItemDataRef }[] = [] + + return [ + (id: string, dataRef: MenuItemDataRef) => items.push({ id, dataRef }), + () => this.send({ type: ActionTypes.RegisterItems, items: items.splice(0) }), + ] + }), + } + + selectors = { + activeDescendantId(state: State) { + let activeItemIndex = state.activeItemIndex + let items = state.items + return activeItemIndex === null ? undefined : items[activeItemIndex]?.id + }, + + isActive(state: State, id: string) { + let activeItemIndex = state.activeItemIndex + let items = state.items + + return activeItemIndex !== null ? items[activeItemIndex]?.id === id : false + }, + + shouldScrollIntoView(state: State, id: string) { + if (state.__demoMode) return false + if (state.menuState !== MenuState.Open) return false + if (state.activationTrigger === ActivationTrigger.Pointer) return false + return this.isActive(state, id) + }, + } +} diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 478b370..7dc7782 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -5,18 +5,13 @@ import { useFocusRing } from '@react-aria/focus' import { useHover } from '@react-aria/interactions' import React, { Fragment, - createContext, useCallback, - useContext, useEffect, useMemo, - useReducer, useRef, useState, type CSSProperties, - type Dispatch, type ElementType, - type MutableRefObject, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type Ref, @@ -50,9 +45,10 @@ import { type AnchorProps, } from '../../internal/floating' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' +import { useSlice } from '../../react-glue' import type { Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' -import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' +import { Focus } from '../../utils/calculate-active-index' import { disposables } from '../../utils/disposables' import { Focus as FocusManagementFocus, @@ -60,7 +56,6 @@ import { focusFrom, isFocusableElement, restoreFocusIfNecessary, - sortByDomNode, } from '../../utils/focus-management' import { match } from '../../utils/match' import { @@ -75,299 +70,8 @@ import { useDescriptions } from '../description/description' import { Keys } from '../keyboard' import { useLabelContext, useLabels } from '../label/label' import { Portal } from '../portal/portal' - -enum MenuStates { - Open, - Closed, -} - -enum ActivationTrigger { - Pointer, - Other, -} - -type MenuItemDataRef = MutableRefObject<{ - textValue?: string - disabled: boolean - domRef: MutableRefObject -}> - -interface StateDefinition { - __demoMode: boolean - menuState: MenuStates - buttonElement: HTMLButtonElement | null - itemsElement: HTMLElement | null - items: { id: string; dataRef: MenuItemDataRef }[] - searchQuery: string - activeItemIndex: number | null - activationTrigger: ActivationTrigger -} - -enum ActionTypes { - OpenMenu, - CloseMenu, - - GoToItem, - Search, - ClearSearch, - RegisterItem, - UnregisterItem, - - SetButtonElement, - SetItemsElement, -} - -function adjustOrderedState( - state: StateDefinition, - adjustment: (items: StateDefinition['items']) => StateDefinition['items'] = (i) => i -) { - let currentActiveItem = state.activeItemIndex !== null ? state.items[state.activeItemIndex] : null - - let sortedItems = sortByDomNode( - adjustment(state.items.slice()), - (item) => item.dataRef.current.domRef.current - ) - - // If we inserted an item before the current active item then the active item index - // would be wrong. To fix this, we will re-lookup the correct index. - let adjustedActiveItemIndex = currentActiveItem ? sortedItems.indexOf(currentActiveItem) : null - - // Reset to `null` in case the currentActiveItem was removed. - if (adjustedActiveItemIndex === -1) { - adjustedActiveItemIndex = null - } - - return { - items: sortedItems, - activeItemIndex: adjustedActiveItemIndex, - } -} - -type Actions = - | { type: ActionTypes.CloseMenu } - | { type: ActionTypes.OpenMenu } - | { type: ActionTypes.GoToItem; focus: Focus.Specific; id: string; trigger?: ActivationTrigger } - | { - type: ActionTypes.GoToItem - focus: Exclude - trigger?: ActivationTrigger - } - | { type: ActionTypes.Search; value: string } - | { type: ActionTypes.ClearSearch } - | { type: ActionTypes.RegisterItem; id: string; dataRef: MenuItemDataRef } - | { type: ActionTypes.UnregisterItem; id: string } - | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } - | { type: ActionTypes.SetItemsElement; element: HTMLElement | null } - -let reducers: { - [P in ActionTypes]: ( - state: StateDefinition, - action: Extract - ) => StateDefinition -} = { - [ActionTypes.CloseMenu](state) { - if (state.menuState === MenuStates.Closed) return state - return { ...state, activeItemIndex: null, menuState: MenuStates.Closed } - }, - [ActionTypes.OpenMenu](state) { - if (state.menuState === MenuStates.Open) return state - return { - ...state, - /* We can turn off demo mode once we re-open the `Menu` */ - __demoMode: false, - menuState: MenuStates.Open, - } - }, - [ActionTypes.GoToItem]: (state, action) => { - if (state.menuState === MenuStates.Closed) return state - - let base = { - ...state, - searchQuery: '', - activationTrigger: action.trigger ?? ActivationTrigger.Other, - __demoMode: false, - } - - // Optimization: - // - // There is no need to sort the DOM nodes if we know that we don't want to focus anything - if (action.focus === Focus.Nothing) { - return { - ...base, - activeItemIndex: null, - } - } - - // Optimization: - // - // There is no need to sort the DOM nodes if we know exactly where to go - if (action.focus === Focus.Specific) { - return { - ...base, - activeItemIndex: state.items.findIndex((o) => o.id === action.id), - } - } - - // Optimization: - // - // If the current DOM node and the previous DOM node are next to each other, - // or if the previous DOM node is already the first DOM node, then we don't - // have to sort all the DOM nodes. - else if (action.focus === Focus.Previous) { - let activeItemIdx = state.activeItemIndex - if (activeItemIdx !== null) { - let currentDom = state.items[activeItemIdx].dataRef.current.domRef - let previousItemIndex = calculateActiveIndex(action, { - resolveItems: () => state.items, - resolveActiveIndex: () => state.activeItemIndex, - resolveId: (item) => item.id, - resolveDisabled: (item) => item.dataRef.current.disabled, - }) - if (previousItemIndex !== null) { - let previousDom = state.items[previousItemIndex].dataRef.current.domRef - if ( - // Next to each other - currentDom.current?.previousElementSibling === previousDom.current || - // Or already the first element - previousDom.current?.previousElementSibling === null - ) { - return { - ...base, - activeItemIndex: previousItemIndex, - } - } - } - } - } - - // Optimization: - // - // If the current DOM node and the next DOM node are next to each other, or - // if the next DOM node is already the last DOM node, then we don't have to - // sort all the DOM nodes. - else if (action.focus === Focus.Next) { - let activeItemIdx = state.activeItemIndex - if (activeItemIdx !== null) { - let currentDom = state.items[activeItemIdx].dataRef.current.domRef - let nextItemIndex = calculateActiveIndex(action, { - resolveItems: () => state.items, - resolveActiveIndex: () => state.activeItemIndex, - resolveId: (item) => item.id, - resolveDisabled: (item) => item.dataRef.current.disabled, - }) - if (nextItemIndex !== null) { - let nextDom = state.items[nextItemIndex].dataRef.current.domRef - if ( - // Next to each other - currentDom.current?.nextElementSibling === nextDom.current || - // Or already the last element - nextDom.current?.nextElementSibling === null - ) { - return { - ...base, - activeItemIndex: nextItemIndex, - } - } - } - } - } - - // Slow path: - // - // Ensure all the items are correctly sorted according to DOM position - let adjustedState = adjustOrderedState(state) - let activeItemIndex = calculateActiveIndex(action, { - resolveItems: () => adjustedState.items, - resolveActiveIndex: () => adjustedState.activeItemIndex, - resolveId: (item) => item.id, - resolveDisabled: (item) => item.dataRef.current.disabled, - }) - - return { - ...base, - ...adjustedState, - activeItemIndex, - } - }, - [ActionTypes.Search]: (state, action) => { - let wasAlreadySearching = state.searchQuery !== '' - let offset = wasAlreadySearching ? 0 : 1 - let searchQuery = state.searchQuery + action.value.toLowerCase() - - let reOrderedItems = - state.activeItemIndex !== null - ? state.items - .slice(state.activeItemIndex + offset) - .concat(state.items.slice(0, state.activeItemIndex + offset)) - : state.items - - let matchingItem = reOrderedItems.find( - (item) => - item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled - ) - - let matchIdx = matchingItem ? state.items.indexOf(matchingItem) : -1 - if (matchIdx === -1 || matchIdx === state.activeItemIndex) return { ...state, searchQuery } - return { - ...state, - searchQuery, - activeItemIndex: matchIdx, - activationTrigger: ActivationTrigger.Other, - } - }, - [ActionTypes.ClearSearch](state) { - if (state.searchQuery === '') return state - return { ...state, searchQuery: '', searchActiveItemIndex: null } - }, - [ActionTypes.RegisterItem]: (state, action) => { - let adjustedState = adjustOrderedState(state, (items) => [ - ...items, - { id: action.id, dataRef: action.dataRef }, - ]) - - return { ...state, ...adjustedState } - }, - [ActionTypes.UnregisterItem]: (state, action) => { - let adjustedState = adjustOrderedState(state, (items) => { - let idx = items.findIndex((a) => a.id === action.id) - if (idx !== -1) items.splice(idx, 1) - return items - }) - - return { - ...state, - ...adjustedState, - activationTrigger: ActivationTrigger.Other, - } - }, - [ActionTypes.SetButtonElement]: (state, action) => { - if (state.buttonElement === action.element) return state - return { ...state, buttonElement: action.element } - }, - [ActionTypes.SetItemsElement]: (state, action) => { - if (state.itemsElement === action.element) return state - return { ...state, itemsElement: action.element } - }, -} - -let MenuContext = createContext<[StateDefinition, Dispatch] | null>(null) -MenuContext.displayName = 'MenuContext' - -function useMenuContext(component: string) { - let context = useContext(MenuContext) - if (context === null) { - let err = new Error(`<${component} /> is missing a parent component.`) - if (Error.captureStackTrace) Error.captureStackTrace(err, useMenuContext) - throw err - } - return context -} - -function stateReducer(state: StateDefinition, action: Actions) { - return match(action.type, reducers, state, action) -} - -// --- +import { ActionTypes, ActivationTrigger, MenuState, type MenuItemDataRef } from './menu-machine' +import { MenuContext, useMenuMachine, useMenuMachineContext } from './menu-machine-glue' let DEFAULT_MENU_TAG = Fragment type MenuRenderPropArg = { @@ -390,36 +94,32 @@ function MenuFn( ref: Ref ) { let { __demoMode = false, ...theirProps } = props - let reducerBag = useReducer(stateReducer, { - __demoMode, - menuState: __demoMode ? MenuStates.Open : MenuStates.Closed, - buttonElement: null, - itemsElement: null, - items: [], - searchQuery: '', - activeItemIndex: null, - activationTrigger: ActivationTrigger.Other, - } as StateDefinition) - let [{ menuState, itemsElement, buttonElement }, dispatch] = reducerBag + let machine = useMenuMachine({ __demoMode }) + + let [menuState, itemsElement, buttonElement] = useSlice(machine, (state) => [ + state.menuState, + state.itemsElement, + state.buttonElement, + ]) let menuRef = useSyncRefs(ref) // Handle outside click - let outsideClickEnabled = menuState === MenuStates.Open + let outsideClickEnabled = menuState === MenuState.Open useOutsideClick(outsideClickEnabled, [buttonElement, itemsElement], (event, target) => { - dispatch({ type: ActionTypes.CloseMenu }) + machine.send({ type: ActionTypes.CloseMenu }) if (!isFocusableElement(target, FocusableMode.Loose)) { event.preventDefault() - buttonElement?.focus() + machine.state.buttonElement?.focus() } }) let close = useEvent(() => { - dispatch({ type: ActionTypes.CloseMenu }) + machine.send({ type: ActionTypes.CloseMenu }) }) let slot = useMemo( - () => ({ open: menuState === MenuStates.Open, close }) satisfies MenuRenderPropArg, + () => ({ open: menuState === MenuState.Open, close }) satisfies MenuRenderPropArg, [menuState, close] ) @@ -429,11 +129,11 @@ function MenuFn( return ( - + {render({ @@ -476,6 +176,7 @@ function ButtonFn( props: MenuButtonProps, ref: Ref ) { + let machine = useMenuMachineContext('Menu.Button') let internalId = useId() let { id = `headlessui-menu-button-${internalId}`, @@ -483,12 +184,13 @@ function ButtonFn( autoFocus = false, ...theirProps } = props - let [state, dispatch] = useMenuContext('Menu.Button') + let internalButtonRef = useRef(null) let getFloatingReferenceProps = useFloatingReferenceProps() let buttonRef = useSyncRefs( ref, + internalButtonRef, useFloatingReference(), - useEvent((element) => dispatch({ type: ActionTypes.SetButtonElement, element })) + useEvent((element) => machine.send({ type: ActionTypes.SetButtonElement, element })) ) let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { @@ -500,15 +202,13 @@ function ButtonFn( case Keys.ArrowDown: event.preventDefault() event.stopPropagation() - flushSync(() => dispatch({ type: ActionTypes.OpenMenu })) - dispatch({ type: ActionTypes.GoToItem, focus: Focus.First }) + machine.send({ type: ActionTypes.OpenMenu, focus: { focus: Focus.First } }) break case Keys.ArrowUp: event.preventDefault() event.stopPropagation() - flushSync(() => dispatch({ type: ActionTypes.OpenMenu })) - dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last }) + machine.send({ type: ActionTypes.OpenMenu, focus: { focus: Focus.Last } }) break } }) @@ -524,15 +224,24 @@ function ButtonFn( } }) + let [menuState, itemsElement] = useSlice(machine, (state) => [ + state.menuState, + state.itemsElement, + ]) + let handleClick = useEvent((event: ReactMouseEvent) => { if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (disabled) return - if (state.menuState === MenuStates.Open) { - flushSync(() => dispatch({ type: ActionTypes.CloseMenu })) - state.buttonElement?.focus({ preventScroll: true }) + if (menuState === MenuState.Open) { + flushSync(() => machine.send({ type: ActionTypes.CloseMenu })) + internalButtonRef.current?.focus({ preventScroll: true }) } else { event.preventDefault() - dispatch({ type: ActionTypes.OpenMenu }) + machine.send({ + type: ActionTypes.OpenMenu, + focus: { focus: Focus.Nothing }, + trigger: ActivationTrigger.Pointer, + }) } }) @@ -542,24 +251,24 @@ function ButtonFn( let slot = useMemo(() => { return { - open: state.menuState === MenuStates.Open, - active: active || state.menuState === MenuStates.Open, + open: menuState === MenuState.Open, + active: active || menuState === MenuState.Open, disabled, hover, focus, autofocus: autoFocus, } satisfies ButtonRenderPropArg - }, [state, hover, focus, active, disabled, autoFocus]) + }, [menuState, hover, focus, active, disabled, autoFocus]) let ourProps = mergeProps( getFloatingReferenceProps(), { ref: buttonRef, id, - type: useResolveButtonType(props, state.buttonElement), + type: useResolveButtonType(props, internalButtonRef.current), 'aria-haspopup': 'menu', - 'aria-controls': state.itemsElement?.id, - 'aria-expanded': state.menuState === MenuStates.Open, + 'aria-controls': itemsElement?.id, + 'aria-expanded': menuState === MenuState.Open, disabled: disabled || undefined, autoFocus, onKeyDown: handleKeyDown, @@ -622,7 +331,7 @@ function ItemsFn( ...theirProps } = props let anchor = useResolvedAnchor(rawAnchor) - let [state, dispatch] = useMenuContext('Menu.Items') + let machine = useMenuMachineContext('Menu.Items') let [floatingRef, style] = useFloatingPanel(anchor) let getFloatingPanelProps = useFloatingPanelProps() @@ -635,11 +344,17 @@ function ItemsFn( let itemsRef = useSyncRefs( ref, anchor ? floatingRef : null, - useEvent((element) => dispatch({ type: ActionTypes.SetItemsElement, element })), + useEvent((element) => machine.send({ type: ActionTypes.SetItemsElement, element })), setLocalItemsElement ) - let portalOwnerDocument = useOwnerDocument(state.buttonElement) - let ownerDocument = useOwnerDocument(state.itemsElement) + + let [menuState, buttonElement] = useSlice(machine, (state) => [ + state.menuState, + state.buttonElement, + ]) + + let portalOwnerDocument = useOwnerDocument(buttonElement) + let ownerDocument = useOwnerDocument(localItemsElement) // Always enable `portal` functionality, when `anchor` is enabled if (anchor) { @@ -652,24 +367,25 @@ function ItemsFn( localItemsElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open - : state.menuState === MenuStates.Open + : menuState === MenuState.Open ) // Ensure we close the menu as soon as the button becomes hidden - useOnDisappear(visible, state.buttonElement, () => { - dispatch({ type: ActionTypes.CloseMenu }) + useOnDisappear(visible, buttonElement, () => { + machine.send({ type: ActionTypes.CloseMenu }) }) // Enable scroll locking when the menu is visible, and `modal` is enabled - let scrollLockEnabled = state.__demoMode ? false : modal && state.menuState === MenuStates.Open + let __demoMode = useSlice(machine, (state) => state.__demoMode) + let scrollLockEnabled = __demoMode ? false : modal && menuState === MenuState.Open useScrollLock(scrollLockEnabled, ownerDocument) // Mark other elements as inert when the menu is visible, and `modal` is enabled - let inertOthersEnabled = state.__demoMode ? false : modal && state.menuState === MenuStates.Open + let inertOthersEnabled = __demoMode ? false : modal && menuState === MenuState.Open useInertOthers(inertOthersEnabled, { allowed: useCallback( - () => [state.buttonElement, state.itemsElement], - [state.buttonElement, state.itemsElement] + () => [buttonElement, localItemsElement], + [buttonElement, localItemsElement] ), }) @@ -682,24 +398,24 @@ function ItemsFn( // // This can be solved by only transitioning the `opacity` instead of everything, but if you _do_ // want to transition the y-axis for example you will run into the same issue again. - let didButtonMoveEnabled = state.menuState !== MenuStates.Open - let didButtonMove = useDidElementMove(didButtonMoveEnabled, state.buttonElement) + let didButtonMoveEnabled = menuState !== MenuState.Open + let didButtonMove = useDidElementMove(didButtonMoveEnabled, buttonElement) // Now that we know that the button did move or not, we can either disable the panel and all of // its transitions, or rely on the `visible` state to hide the panel whenever necessary. let panelEnabled = didButtonMove ? false : visible useEffect(() => { - let container = state.itemsElement + let container = localItemsElement if (!container) return - if (state.menuState !== MenuStates.Open) return + if (menuState !== MenuState.Open) return if (container === ownerDocument?.activeElement) return container.focus({ preventScroll: true }) - }, [state.menuState, state.itemsElement, ownerDocument]) + }, [menuState, localItemsElement, ownerDocument]) - useTreeWalker(state.menuState === MenuStates.Open, { - container: state.itemsElement, + useTreeWalker(menuState === MenuState.Open, { + container: localItemsElement, accept(node) { if (node.getAttribute('role') === 'menuitem') return NodeFilter.FILTER_REJECT if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP @@ -719,66 +435,66 @@ function ItemsFn( // @ts-expect-error Fallthrough is expected here case Keys.Space: - if (state.searchQuery !== '') { + if (machine.state.searchQuery !== '') { event.preventDefault() event.stopPropagation() - return dispatch({ type: ActionTypes.Search, value: event.key }) + return machine.send({ type: ActionTypes.Search, value: event.key }) } // When in type ahead mode, fallthrough case Keys.Enter: event.preventDefault() event.stopPropagation() - dispatch({ type: ActionTypes.CloseMenu }) - if (state.activeItemIndex !== null) { - let { dataRef } = state.items[state.activeItemIndex] + if (machine.state.activeItemIndex !== null) { + let { dataRef } = machine.state.items[machine.state.activeItemIndex] dataRef.current?.domRef.current?.click() } - restoreFocusIfNecessary(state.buttonElement) + machine.send({ type: ActionTypes.CloseMenu }) + restoreFocusIfNecessary(machine.state.buttonElement) break case Keys.ArrowDown: event.preventDefault() event.stopPropagation() - return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Next }) + return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Next }) case Keys.ArrowUp: event.preventDefault() event.stopPropagation() - return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Previous }) + return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Previous }) case Keys.Home: case Keys.PageUp: event.preventDefault() event.stopPropagation() - return dispatch({ type: ActionTypes.GoToItem, focus: Focus.First }) + return machine.send({ type: ActionTypes.GoToItem, focus: Focus.First }) case Keys.End: case Keys.PageDown: event.preventDefault() event.stopPropagation() - return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last }) + return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Last }) case Keys.Escape: event.preventDefault() event.stopPropagation() - flushSync(() => dispatch({ type: ActionTypes.CloseMenu })) - state.buttonElement?.focus({ preventScroll: true }) + flushSync(() => machine.send({ type: ActionTypes.CloseMenu })) + machine.state.buttonElement?.focus({ preventScroll: true }) break case Keys.Tab: event.preventDefault() event.stopPropagation() - flushSync(() => dispatch({ type: ActionTypes.CloseMenu })) + flushSync(() => machine.send({ type: ActionTypes.CloseMenu })) focusFrom( - state.buttonElement!, + machine.state.buttonElement!, event.shiftKey ? FocusManagementFocus.Previous : FocusManagementFocus.Next ) break default: if (event.key.length === 1) { - dispatch({ type: ActionTypes.Search, value: event.key }) - searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350) + machine.send({ type: ActionTypes.Search, value: event.key }) + searchDisposables.setTimeout(() => machine.send({ type: ActionTypes.ClearSearch }), 350) } break } @@ -797,14 +513,13 @@ function ItemsFn( let slot = useMemo(() => { return { - open: state.menuState === MenuStates.Open, + open: menuState === MenuState.Open, } satisfies ItemsRenderPropArg - }, [state.menuState]) + }, [menuState]) let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, { - 'aria-activedescendant': - state.activeItemIndex === null ? undefined : state.items[state.activeItemIndex]?.id, - 'aria-labelledby': state.buttonElement?.id, + 'aria-activedescendant': useSlice(machine, machine.selectors.activeDescendantId), + 'aria-labelledby': useSlice(machine, (state) => state.buttonElement?.id), id, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, @@ -812,12 +527,12 @@ function ItemsFn( // When the `Menu` is closed, it should not be focusable. This allows us // to skip focusing the `MenuItems` when pressing the tab key on an // open `Menu`, and go to the next focusable element. - tabIndex: state.menuState === MenuStates.Open ? 0 : undefined, + tabIndex: menuState === MenuState.Open ? 0 : undefined, ref: itemsRef, style: { ...theirProps.style, ...style, - '--button-width': useElementSize(state.buttonElement, true).width, + '--button-width': useElementSize(buttonElement, true).width, } as CSSProperties, ...transitionDataAttributes(transitionData), }) @@ -871,27 +586,22 @@ function ItemFn( ) { let internalId = useId() let { id = `headlessui-menu-item-${internalId}`, disabled = false, ...theirProps } = props - let [state, dispatch] = useMenuContext('Menu.Item') - let active = state.activeItemIndex !== null ? state.items[state.activeItemIndex].id === id : false + let machine = useMenuMachineContext('Menu.Item') + + let active = useSlice(machine, (state) => machine.selectors.isActive(state, id)) + let internalItemRef = useRef(null) let itemRef = useSyncRefs(ref, internalItemRef) + let shouldScrollIntoView = useSlice(machine, (state) => + machine.selectors.shouldScrollIntoView(state, id) + ) useIsoMorphicEffect(() => { - if (state.__demoMode) return - if (state.menuState !== MenuStates.Open) return - if (!active) return - if (state.activationTrigger === ActivationTrigger.Pointer) return + if (!shouldScrollIntoView) return return disposables().requestAnimationFrame(() => { internalItemRef.current?.scrollIntoView?.({ block: 'nearest' }) }) - }, [ - state.__demoMode, - internalItemRef, - active, - state.menuState, - state.activationTrigger, - /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeItemIndex, - ]) + }, [shouldScrollIntoView, internalItemRef]) let getTextValue = useTextValue(internalItemRef) @@ -908,23 +618,23 @@ function ItemFn( }, [bag, disabled]) useIsoMorphicEffect(() => { - dispatch({ type: ActionTypes.RegisterItem, id, dataRef: bag }) - return () => dispatch({ type: ActionTypes.UnregisterItem, id }) + machine.actions.registerItem(id, bag) + return () => machine.send({ type: ActionTypes.UnregisterItem, id }) }, [bag, id]) let close = useEvent(() => { - dispatch({ type: ActionTypes.CloseMenu }) + machine.send({ type: ActionTypes.CloseMenu }) }) let handleClick = useEvent((event: MouseEvent) => { if (disabled) return event.preventDefault() - dispatch({ type: ActionTypes.CloseMenu }) - restoreFocusIfNecessary(state.buttonElement) + machine.send({ type: ActionTypes.CloseMenu }) + restoreFocusIfNecessary(machine.state.buttonElement) }) let handleFocus = useEvent(() => { - if (disabled) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) - dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id }) + if (disabled) return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) + machine.send({ type: ActionTypes.GoToItem, focus: Focus.Specific, id }) }) let pointer = useTrackedPointer() @@ -933,7 +643,7 @@ function ItemFn( pointer.update(evt) if (disabled) return if (active) return - dispatch({ + machine.send({ type: ActionTypes.GoToItem, focus: Focus.Specific, id, @@ -945,7 +655,7 @@ function ItemFn( if (!pointer.wasMoved(evt)) return if (disabled) return if (active) return - dispatch({ + machine.send({ type: ActionTypes.GoToItem, focus: Focus.Specific, id, @@ -957,7 +667,7 @@ function ItemFn( if (!pointer.wasMoved(evt)) return if (disabled) return if (!active) return - dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) + machine.send({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) }) let [labelledby, LabelProvider] = useLabels()