Skip to content

Commit 97cb208

Browse files
committed
Improve Combobox component performance (#3697)
This PR improves the performance of the `Combobox` component. This is a similar implementation as: - #3685 - #3688 Before this PR, the `Combobox` component is built in a way where all the state lives in the `Combobox` 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 `Combobox` component, and "subscribe" to state changes using the `useSlice` hook introduced in #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 `ComboboxOptions` will re-render and have an updated `aria-activedescendant` 2. The `ComboboxOption` that _was_ active, will re-render and the `data-focus` attribute wil be removed. 3. The `ComboboxOption` that is now active, will re-render and the `data-focus` attribute wil be added. The `Combobox` component already has a `virtual` option if you want to render many many more items. This is a bit of a different model where all the options are passed in via an array instead of rendering all `ComboboxOption` components immediately. Because of this, I didn't want to batch the registration of the options as part of this PR (similar to what we do in the `Menu` and `Listbox`) because it behaves differently compared to what mode you are using (virtual or not). Since not all components will be rendered, batching the registration until everything is registered doesn't really make sense in the general case. However, it does make sense in non-virtual mode. But because of this difference, I didn't want to implement this as part of this PR and increase the complexity of the PR even more. Instead I will follow up with more PRs with more improvements. But the key improvement of looking at the slice of the data is what makes the biggest impact. This also means that we can do another release once this is merged. Last but not least, recently we fixed a bug where the `Combobox` in `virtual` mode could crash if you search for an item that doesn't exist. To solve it, we implemented a workaround in: - #3678 Which used a double `requestAnimationFrame` to delay the scrolling to the item. While this solved this issue, this also caused visual flicker when holding down your arrow keys. I also fixed it in this PR by introducing `patch-package` and work around the issue in the `@tanstack/virtual-core` package itself. More info: 96f4da7 Before: https://github.com/user-attachments/assets/132520d3-b4d6-42f9-9152-57427de20639 After: https://github.com/user-attachments/assets/41f198fe-9326-42d1-a09f-077b60a9f65d ## 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/945692a3-96e6-4ac7-bee0-36a1fd89172b After: https://github.com/user-attachments/assets/98a151d0-16cc-4823-811c-fcee0019937a
1 parent c5f95b0 commit 97cb208

File tree

12 files changed

+2041
-1464
lines changed

12 files changed

+2041
-1464
lines changed

package-lock.json

Lines changed: 437 additions & 51 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"lint-types": "CI=true npm run lint-types --workspaces --if-present",
2626
"release-channel": "node ./scripts/release-channel.js",
2727
"release-notes": "node ./scripts/release-notes.js",
28-
"package-path": "node ./scripts/package-path.js"
28+
"package-path": "node ./scripts/package-path.js",
29+
"postinstall": "patch-package"
2930
},
3031
"husky": {
3132
"hooks": {
@@ -76,6 +77,7 @@
7677
"jest": "26",
7778
"lint-staged": "^12.2.1",
7879
"npm-run-all": "^4.1.5",
80+
"patch-package": "^8.0.0",
7981
"prettier": "^3.1.0",
8082
"prettier-plugin-organize-imports": "^3.2.4",
8183
"prettier-plugin-tailwindcss": "^0.6.11",

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Improve `Listbox` component performance ([#3688](https://github.com/tailwindlabs/headlessui/pull/3688))
1414
- Open `Menu` and `Listbox` on `mousedown` ([#3689](https://github.com/tailwindlabs/headlessui/pull/3689))
1515
- Fix `Transition` component from incorrectly exposing the `Closing` state ([#3696](https://github.com/tailwindlabs/headlessui/pull/3696))
16+
- Improve `Combobox` component performance ([#3697](https://github.com/tailwindlabs/headlessui/pull/3697))
1617

1718
## [2.2.1] - 2025-04-04
1819

packages/@headlessui-react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"@floating-ui/react": "^0.26.16",
6060
"@react-aria/focus": "^3.17.1",
6161
"@react-aria/interactions": "^3.21.3",
62-
"@tanstack/react-virtual": "^3.11.1",
62+
"@tanstack/react-virtual": "^3.13.6",
6363
"use-sync-external-store": "^1.5.0"
6464
}
6565
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createContext, useContext, useMemo } from 'react'
2+
import { ComboboxMachine } from './combobox-machine'
3+
4+
export const ComboboxContext = createContext<ComboboxMachine<unknown> | null>(null)
5+
export function useComboboxMachineContext<T>(component: string) {
6+
let context = useContext(ComboboxContext)
7+
if (context === null) {
8+
let err = new Error(`<${component} /> is missing a parent <Combobox /> component.`)
9+
if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxMachine)
10+
throw err
11+
}
12+
return context as ComboboxMachine<T>
13+
}
14+
15+
export function useComboboxMachine({
16+
virtual = null,
17+
__demoMode = false,
18+
}: Parameters<typeof ComboboxMachine.new>[0] = {}) {
19+
return useMemo(() => ComboboxMachine.new({ virtual, __demoMode }), [])
20+
}

0 commit comments

Comments
 (0)