diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index f8c3cc473d..4b7fe6d887 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure `ComboboxInput` does not sync with current value while typing ([#3259](https://github.com/tailwindlabs/headlessui/pull/3259))
- Cancel outside click behavior on touch devices when scrolling ([#3266](https://github.com/tailwindlabs/headlessui/pull/3266))
- Correctly apply conditional classses when using `` and `` components ([#3303](https://github.com/tailwindlabs/headlessui/pull/3303))
+- Improve UX by freezing `ComboboxOptions` while closing ([#3304](https://github.com/tailwindlabs/headlessui/pull/3304))
### Changed
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index 6927a4e7f1..f89e5140a8 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -54,6 +54,7 @@ import {
type AnchorProps,
} from '../../internal/floating'
import { FormFields } from '../../internal/form-fields'
+import { Frozen, useFrozenData } from '../../internal/frozen'
import { useProvidedId } from '../../internal/id'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import type { EnsureArray, Props } from '../../types'
@@ -1707,28 +1708,38 @@ function OptionsFn(
onMouseDown: handleMouseDown,
})
+ // We should freeze when the combobox is visible but "closed". This means that
+ // a transition is currently happening and the component is still visible (for
+ // the transition) but closed from a functionality perspective.
+ let shouldFreeze = visible && data.comboboxState === ComboboxState.Closed
+
+ let options = useFrozenData(shouldFreeze, data.virtual?.options)
+
+ // Frozen state, the selected value will only update visually when the user re-opens the
+ let frozenValue = useFrozenData(shouldFreeze, data.value)
+
+ let isSelected = useEvent((compareValue) => data.compare(frozenValue, compareValue))
+
// Map the children in a scrollable container when virtualization is enabled
- if (data.virtual && visible) {
+ if (data.virtual) {
+ if (options === undefined) throw new Error('Missing `options` in virtual mode')
+
Object.assign(theirProps, {
- // @ts-expect-error The `children` prop now is a callback function that receives `{ option }`.
- children: {theirProps.children},
+ children: (
+
+ {/* @ts-expect-error The `children` prop now is a callback function that receives `{option}` */}
+ {theirProps.children}
+
+ ),
})
}
- // Frozen state, the selected value will only update visually when the user re-opens the
- let [frozenValue, setFrozenValue] = useState(data.value)
- if (
- data.value !== frozenValue &&
- data.comboboxState === ComboboxState.Open &&
- data.mode !== ValueMode.Multi
- ) {
- setFrozenValue(data.value)
- }
-
- let isSelected = useEvent((compareValue: unknown) => {
- return data.compare(frozenValue, compareValue)
- })
-
return (
(
>
{render({
ourProps,
- theirProps,
+ theirProps: {
+ ...theirProps,
+ children: (
+
+ {typeof theirProps.children === 'function'
+ ? // @ts-expect-error The `children` prop now is a callback function
+ theirProps.children?.(slot)
+ : theirProps.children}
+
+ ),
+ },
slot,
defaultTag: DEFAULT_OPTIONS_TAG,
features: OptionsRenderFeatures,
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index 3cf106953f..6f84675b5d 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -12,7 +12,6 @@ import React, {
useMemo,
useReducer,
useRef,
- useState,
type CSSProperties,
type ElementType,
type MutableRefObject,
@@ -54,6 +53,7 @@ import {
type AnchorPropsWithSelection,
} from '../../internal/floating'
import { FormFields } from '../../internal/form-fields'
+import { useFrozenData } from '../../internal/frozen'
import { useProvidedId } from '../../internal/id'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import type { EnsureArray, Props } from '../../types'
@@ -1115,18 +1115,15 @@ function OptionsFn(
} as CSSProperties,
})
+ // We should freeze when the listbox is visible but "closed". This means that
+ // a transition is currently happening and the component is still visible (for
+ // the transition) but closed from a functionality perspective.
+ let shouldFreeze = visible && data.listboxState === ListboxStates.Closed
+
// Frozen state, the selected value will only update visually when the user re-opens the
- let [frozenValue, setFrozenValue] = useState(data.value)
- if (
- data.value !== frozenValue &&
- data.listboxState === ListboxStates.Open &&
- data.mode !== ValueMode.Multi
- ) {
- setFrozenValue(data.value)
- }
- let isSelected = useEvent((compareValue: unknown) => {
- return data.compare(frozenValue, compareValue)
- })
+ let frozenValue = useFrozenData(shouldFreeze, data.value)
+
+ let isSelected = useEvent((compareValue: unknown) => data.compare(frozenValue, compareValue))
return (
diff --git a/packages/@headlessui-react/src/internal/frozen.tsx b/packages/@headlessui-react/src/internal/frozen.tsx
new file mode 100644
index 0000000000..6deffe31b5
--- /dev/null
+++ b/packages/@headlessui-react/src/internal/frozen.tsx
@@ -0,0 +1,19 @@
+import React, { useState } from 'react'
+
+export function Frozen({ children, freeze }: { children: React.ReactNode; freeze: boolean }) {
+ let contents = useFrozenData(freeze, children)
+ return <>{contents}>
+}
+
+export function useFrozenData(freeze: boolean, data: T) {
+ let [frozenValue, setFrozenValue] = useState(data)
+
+ // We should keep updating the frozen value, as long as we shouldn't freeze
+ // the value yet. The moment we should freeze the value we stop updating it
+ // which allows us to reference the "previous" (thus frozen) value.
+ if (!freeze && frozenValue !== data) {
+ setFrozenValue(data)
+ }
+
+ return freeze ? frozenValue : data
+}
diff --git a/playgrounds/react/pages/combobox/combobox-countries.tsx b/playgrounds/react/pages/combobox/combobox-countries.tsx
index 0ae9a813e1..02a7a0b5ad 100644
--- a/playgrounds/react/pages/combobox/combobox-countries.tsx
+++ b/playgrounds/react/pages/combobox/combobox-countries.tsx
@@ -72,51 +72,53 @@ export default function Home() {
-