Repository: tailwindlabs/headlessui
Pull request #3690 (2025-04-10 22:00:11)

Improve performance of `Listbox` and `Menu` when closing
In a previous PR, we already batched registering options. This PR also batches unregistering options to make the closing behavior smoother when there are a lot of items rendered. 


Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 4db2126..152ea70 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 - Improve `Menu` component performance ([#3685](https://github.com/tailwindlabs/headlessui/pull/3685))
 - Improve `Listbox` component performance ([#3688](https://github.com/tailwindlabs/headlessui/pull/3688))
+- Open `Menu` and `Listbox` on `mousedown` ([#3689](https://github.com/tailwindlabs/headlessui/pull/3689))
 
 ## [2.2.1] - 2025-04-04
 
diff --git a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts
index c1c75a2..9004cdc 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts
+++ b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts
@@ -72,7 +72,7 @@ export enum ActionTypes {
   ClearSearch,
 
   RegisterOptions,
-  UnregisterOption,
+  UnregisterOptions,
 
   SetButtonElement,
   SetOptionsElement,
@@ -124,7 +124,7 @@ type Actions<T> =
       type: ActionTypes.RegisterOptions
       options: { id: string; dataRef: ListboxOptionDataRef<T> }[]
     }
-  | { type: ActionTypes.UnregisterOption; id: string }
+  | { type: ActionTypes.UnregisterOptions; options: string[] }
   | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null }
   | { type: ActionTypes.SetOptionsElement; element: HTMLElement | null }
   | { type: ActionTypes.SortOptions }
@@ -328,12 +328,24 @@ let reducers: {
       pendingShouldSort: true,
     }
   },
-  [ActionTypes.UnregisterOption]: (state, action) => {
+  [ActionTypes.UnregisterOptions]: (state, action) => {
     let options = state.options
-    let idx = options.findIndex((a) => a.id === action.id)
-    if (idx !== -1) {
+
+    let idxs = []
+    let ids = new Set(action.options)
+    for (let [idx, option] of options.entries()) {
+      if (ids.has(option.id)) {
+        idxs.push(idx)
+        ids.delete(option.id)
+        if (ids.size === 0) break
+      }
+    }
+
+    if (idxs.length > 0) {
       options = options.slice()
-      options.splice(idx, 1)
+      for (let idx of idxs.reverse()) {
+        options.splice(idx, 1)
+      }
     }
 
     return {
@@ -421,6 +433,15 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
         },
       ]
     }),
+    unregisterOption: batch(() => {
+      let options: string[] = []
+      return [
+        (id: string) => options.push(id),
+        () => {
+          this.send({ type: ActionTypes.UnregisterOptions, options: options.splice(0) })
+        },
+      ]
+    }),
     goToOption: batch(() => {
       let last: Extract<Actions<unknown>, { type: ActionTypes.GoToOption }> | null = null
       return [
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index c3a333a..6384a2e 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -393,7 +393,8 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
     }
   })
 
-  let handleClick = useEvent((event: ReactMouseEvent) => {
+  let handleMouseDown = useEvent((event: ReactMouseEvent) => {
+    if (event.button !== 0) return // Only handle left clicks
     if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
     if (machine.state.listboxState === ListboxStates.Open) {
       flushSync(() => machine.actions.closeListbox())
@@ -450,7 +451,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
       onKeyDown: handleKeyDown,
       onKeyUp: handleKeyUp,
       onKeyPress: handleKeyPress,
-      onClick: handleClick,
+      onMouseDown: handleMouseDown,
     },
     focusProps,
     hoverProps,
@@ -849,9 +850,7 @@ function OptionFn<
   useIsoMorphicEffect(() => {
     if (usedInSelectedOption) return
     machine.actions.registerOption(id, bag)
-    return () => {
-      machine.send({ type: ActionTypes.UnregisterOption, id })
-    }
+    return () => machine.actions.unregisterOption(id)
   }, [bag, id, usedInSelectedOption])
 
   let handleClick = useEvent((event: { preventDefault: Function }) => {
diff --git a/packages/@headlessui-react/src/components/menu/menu-machine.ts b/packages/@headlessui-react/src/components/menu/menu-machine.ts
index 7177187..0ab812e 100644
--- a/packages/@headlessui-react/src/components/menu/menu-machine.ts
+++ b/packages/@headlessui-react/src/components/menu/menu-machine.ts
@@ -45,7 +45,7 @@ export enum ActionTypes {
   Search,
   ClearSearch,
   RegisterItems,
-  UnregisterItem,
+  UnregisterItems,
 
   SetButtonElement,
   SetItemsElement,
@@ -95,7 +95,7 @@ export type Actions =
   | { type: ActionTypes.Search; value: string }
   | { type: ActionTypes.ClearSearch }
   | { type: ActionTypes.RegisterItems; items: { id: string; dataRef: MenuItemDataRef }[] }
-  | { type: ActionTypes.UnregisterItem; id: string }
+  | { type: ActionTypes.UnregisterItems; items: string[] }
   | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null }
   | { type: ActionTypes.SetItemsElement; element: HTMLElement | null }
   | { type: ActionTypes.SortItems }
@@ -283,12 +283,24 @@ let reducers: {
       pendingShouldSort: true,
     }
   },
-  [ActionTypes.UnregisterItem]: (state, action) => {
+  [ActionTypes.UnregisterItems]: (state, action) => {
     let items = state.items
-    let idx = items.findIndex((a) => a.id === action.id)
-    if (idx !== -1) {
+
+    let idxs = []
+    let ids = new Set(action.items)
+    for (let [idx, item] of items.entries()) {
+      if (ids.has(item.id)) {
+        idxs.push(idx)
+        ids.delete(item.id)
+        if (ids.size === 0) break
+      }
+    }
+
+    if (idxs.length > 0) {
       items = items.slice()
-      items.splice(idx, 1)
+      for (let idx of idxs.reverse()) {
+        items.splice(idx, 1)
+      }
     }
 
     return {
@@ -297,6 +309,7 @@ let reducers: {
       activationTrigger: ActivationTrigger.Other,
     }
   },
+
   [ActionTypes.SetButtonElement]: (state, action) => {
     if (state.buttonElement === action.element) return state
     return { ...state, buttonElement: action.element }
@@ -359,6 +372,14 @@ export class MenuMachine extends Machine<State, Actions> {
         () => this.send({ type: ActionTypes.RegisterItems, items: items.splice(0) }),
       ]
     }),
+    unregisterItem: batch(() => {
+      let items: string[] = []
+
+      return [
+        (id: string) => items.push(id),
+        () => this.send({ type: ActionTypes.UnregisterItems, items: items.splice(0) }),
+      ]
+    }),
   }
 
   selectors = {
diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx
index 7dc7782..29d0acf 100644
--- a/packages/@headlessui-react/src/components/menu/menu.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.tsx
@@ -229,7 +229,8 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
     state.itemsElement,
   ])
 
-  let handleClick = useEvent((event: ReactMouseEvent) => {
+  let handleMouseDown = useEvent((event: ReactMouseEvent) => {
+    if (event.button !== 0) return // Only handle left clicks
     if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
     if (disabled) return
     if (menuState === MenuState.Open) {
@@ -273,7 +274,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
       autoFocus,
       onKeyDown: handleKeyDown,
       onKeyUp: handleKeyUp,
-      onClick: handleClick,
+      onMouseDown: handleMouseDown,
     },
     focusProps,
     hoverProps,
@@ -619,7 +620,7 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
 
   useIsoMorphicEffect(() => {
     machine.actions.registerItem(id, bag)
-    return () => machine.send({ type: ActionTypes.UnregisterItem, id })
+    return () => machine.actions.unregisterItem(id)
   }, [bag, id])
 
   let close = useEvent(() => {


Test patch:
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
index f606fc9..6ddac4d 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
@@ -3963,11 +3963,10 @@ describe('Mouse interactions', () => {
   it(
     'should be possible to click outside of the listbox, on an element which is within a focusable element, which closes the listbox',
     suppressConsoleLogs(async () => {
-      let focusFn = jest.fn()
       render(
         <div>
           <Listbox value={undefined} onChange={(x) => console.log(x)}>
-            <Listbox.Button onFocus={focusFn}>Trigger</Listbox.Button>
+            <Listbox.Button>Trigger</Listbox.Button>
             <Listbox.Options>
               <Listbox.Option value="alice">alice</Listbox.Option>
               <Listbox.Option value="bob">bob</Listbox.Option>
@@ -3995,9 +3994,6 @@ describe('Mouse interactions', () => {
 
       // Ensure the outside button is focused
       assertActiveElement(document.getElementById('btn'))
-
-      // Ensure that the focus button only got focus once (first click)
-      expect(focusFn).toHaveBeenCalledTimes(1)
     })
   )
 
diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx
index a3ed4d2..fe994e6 100644
--- a/packages/@headlessui-react/src/components/menu/menu.test.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx
@@ -3077,11 +3077,10 @@ describe('Mouse interactions', () => {
   it(
     'should be possible to click outside of the menu, on an element which is within a focusable element, which closes the menu',
     suppressConsoleLogs(async () => {
-      let focusFn = jest.fn()
       render(
         <div>
           <Menu>
-            <Menu.Button onFocus={focusFn}>Trigger</Menu.Button>
+            <Menu.Button>Trigger</Menu.Button>
             <Menu.Items>
               <Menu.Item as="a">alice</Menu.Item>
               <Menu.Item as="a">bob</Menu.Item>
@@ -3109,9 +3108,6 @@ describe('Mouse interactions', () => {
 
       // Ensure the outside button is focused
       assertActiveElement(document.getElementById('btn'))
-
-      // Ensure that the focus button only got focus once (first click)
-      expect(focusFn).toHaveBeenCalledTimes(1)
     })
   )