From 26de23ae2276bd37cfeeb996768b735fc79b1d19 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 25 Sep 2025 11:53:48 +0200 Subject: [PATCH 1/7] check if there are pending transitions We are only interested in CSS Transitions not Animations in this case. We are also interested in the ones that are not `finished` yet. We implemented this in Elements as well. --- packages/@headlessui-react/src/hooks/use-transition.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts index c9f33074da..dd6fef96b1 100644 --- a/packages/@headlessui-react/src/hooks/use-transition.ts +++ b/packages/@headlessui-react/src/hooks/use-transition.ts @@ -165,7 +165,7 @@ export function useTransition( }, done() { if (cancelledRef.current) { - if (typeof element.getAnimations === 'function' && element.getAnimations().length > 0) { + if (hasPendingTransitions(element)) { return } } @@ -304,3 +304,11 @@ function prepareTransition( // Reset the transition to what it was before node.style.transition = previous } + +function hasPendingTransitions(node: HTMLElement) { + let animations = node.getAnimations?.() ?? [] + + return animations.some((animation) => { + return animation instanceof CSSTransition && animation.playState !== 'finished' + }) +} From 272a2b7f4cab6e8b0b562022500d49d895036fe0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 25 Sep 2025 14:25:44 +0200 Subject: [PATCH 2/7] track the frozen value in state The moment we select an option in single value mode we will freeze the state immediately. This means that we don't have to rely on additional re-renders to freeze the value which could be too late. It's too late when between the `onChange` and `closeListbox` a re-render happens with the new value. Because then we would freeze the new value which would be wrong. This also slightly refactors the `selectActiveOption` and `selectOption` actions. --- .../src/components/listbox/listbox-machine.ts | 56 +++++++++++++++---- .../src/components/listbox/listbox.tsx | 22 +++----- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts index 2a8caa6185..9e8b774180 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts +++ b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts @@ -65,6 +65,8 @@ interface State { activeOptionIndex: number | null activationTrigger: ActivationTrigger + frozenValue: boolean + buttonElement: HTMLButtonElement | null optionsElement: HTMLElement | null @@ -82,6 +84,7 @@ export enum ActionTypes { GoToOption, Search, ClearSearch, + SelectOption, RegisterOptions, UnregisterOptions, @@ -137,6 +140,7 @@ type Actions = } | { type: ActionTypes.Search; value: string } | { type: ActionTypes.ClearSearch } + | { type: ActionTypes.SelectOption; value: T } | { type: ActionTypes.RegisterOptions options: { id: string; dataRef: ListboxOptionDataRef }[] @@ -181,6 +185,7 @@ let reducers: { return { ...state, + frozenValue: false, pendingFocus: action.focus, listboxState: ListboxStates.Open, activeOptionIndex, @@ -338,6 +343,22 @@ let reducers: { if (state.searchQuery === '') return state return { ...state, searchQuery: '' } }, + [ActionTypes.SelectOption](state) { + if (state.dataRef.current.mode === ValueMode.Single) { + // The moment you select a value in single value mode, we want to close + // the listbox and freeze the value to prevent UI flicker. + return { ...state, frozenValue: true } + } + + // We have an event listener for `SelectOption`, but that will only be + // called when the state changed. In multi-value mode we don't have a state + // change but we still want to trigger the event listener. Therefore we + // return a new object to trigger that event. + // + // Not the cleanest, but that's why we have this, instead of just returning + // `state`. + return { ...state } + }, [ActionTypes.RegisterOptions]: (state, action) => { let options = state.options.concat(action.options) @@ -436,6 +457,7 @@ export class ListboxMachine extends Machine, Actions> { optionsElement: null, pendingShouldSort: false, pendingFocus: { focus: Focus.Nothing }, + frozenValue: false, __demoMode, buttonPositionState: ElementPositionState.Idle, }) @@ -487,6 +509,15 @@ export class ListboxMachine extends Machine, Actions> { ) }) }) + + this.on(ActionTypes.SelectOption, (_, action) => { + this.actions.onChange(action.value) + + if (this.state.dataRef.current.mode === ValueMode.Single) { + this.actions.closeListbox() + this.state.buttonElement?.focus({ preventScroll: true }) + } + }) } actions = { @@ -556,22 +587,23 @@ export class ListboxMachine extends Machine, Actions> { ) => { this.send({ type: ActionTypes.OpenListbox, focus }) }, + selectActiveOption: () => { if (this.state.activeOptionIndex !== null) { - let { dataRef, id } = this.state.options[this.state.activeOptionIndex] - this.actions.onChange(dataRef.current.value) - - // It could happen that the `activeOptionIndex` stored in state is actually null, - // but we are getting the fallback active option back instead. - this.send({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + let { dataRef } = this.state.options[this.state.activeOptionIndex] + this.actions.selectOption(dataRef.current.value) + } else { + if (this.state.dataRef.current.mode === ValueMode.Single) { + this.actions.closeListbox() + this.state.buttonElement?.focus({ preventScroll: true }) + } } }, - selectOption: (id: string) => { - let option = this.state.options.find((item) => item.id === id) - if (!option) return - this.actions.onChange(option.dataRef.current.value) + selectOption: (value: T) => { + this.send({ type: ActionTypes.SelectOption, value }) }, + search: (value: string) => { this.send({ type: ActionTypes.Search, value }) }, @@ -600,6 +632,10 @@ export class ListboxMachine extends Machine, Actions> { return activeOptionIndex !== null ? options[activeOptionIndex]?.id === id : false }, + hasFrozenValue(state: State) { + return state.frozenValue + }, + shouldScrollIntoView(state: State, id: string) { if (state.__demoMode) return false if (state.listboxState !== ListboxStates.Open) return false diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 74bcef5f22..245df7a51e 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -589,13 +589,18 @@ function OptionsFn( // the panel whenever necessary. let panelEnabled = didButtonMove ? false : visible + // The moment we picked a value in single value mode, the value should be + // frozen immediately. + let hasFrozenValue = useSlice(machine, machine.selectors.hasFrozenValue) && transition + // 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. // // When the `static` prop is used, we should never freeze, because rendering // is up to the user. - let shouldFreeze = visible && listboxState === ListboxStates.Closed && !props.static + let shouldFreeze = + (hasFrozenValue || (visible && listboxState === ListboxStates.Closed)) && !props.static // Frozen state, the selected value will only update visually when the user re-opens the let frozenValue = useFrozenData(shouldFreeze, data.value) @@ -671,14 +676,7 @@ function OptionsFn( event.preventDefault() event.stopPropagation() - if (machine.state.activeOptionIndex !== null) { - let { dataRef } = machine.state.options[machine.state.activeOptionIndex] - machine.actions.onChange(dataRef.current.value) - } - if (data.mode === ValueMode.Single) { - flushSync(() => machine.actions.closeListbox()) - machine.state.buttonElement?.focus({ preventScroll: true }) - } + machine.actions.selectActiveOption() break case match(data.orientation, { @@ -872,11 +870,7 @@ function OptionFn< let handleClick = useEvent((event: { preventDefault: Function }) => { if (disabled) return event.preventDefault() - machine.actions.onChange(value) - if (data.mode === ValueMode.Single) { - flushSync(() => machine.actions.closeListbox()) - machine.state.buttonElement?.focus({ preventScroll: true }) - } + machine.actions.selectOption(value) }) let handleFocus = useEvent(() => { From c1efb73b167a737a0af3131fcfcba1f1b767a96b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 25 Sep 2025 14:47:41 +0200 Subject: [PATCH 3/7] update changelog --- packages/@headlessui-react/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index fb7a7dd84a..9f1082e921 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Infer `Combobox` type based on `onChange` handler ([#3798](https://github.com/tailwindlabs/headlessui/pull/3798)) - Allow home/end key default behavior inside `ComboboxInput` when `Combobox` is closed ([#3798](https://github.com/tailwindlabs/headlessui/pull/3798)) - Ensure interacting with a `Dialog` on iOS works after interacting with a disallowed area ([#3801](https://github.com/tailwindlabs/headlessui/pull/3801)) +- Freeze Listbox values as soon as possible when closing ([#3802](https://github.com/tailwindlabs/headlessui/pull/3802)) ## [2.2.8] - 2025-09-12 From 24227f81fb46ec1771827754a13ddba53319c66d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 25 Sep 2025 14:54:35 +0200 Subject: [PATCH 4/7] Apply suggestion from @thecrypticace Co-authored-by: Jordan Pittman --- .../@headlessui-react/src/components/listbox/listbox-machine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts index 9e8b774180..1a9b0fd0c8 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts +++ b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts @@ -351,7 +351,7 @@ let reducers: { } // We have an event listener for `SelectOption`, but that will only be - // called when the state changed. In multi-value mode we don't have a state + // called when the state changes. In multi-value mode we don't have a state // change but we still want to trigger the event listener. Therefore we // return a new object to trigger that event. // From f4c11aadcfd0d6296112166d64fd1deaa890894b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 25 Sep 2025 14:58:46 +0200 Subject: [PATCH 5/7] simplify frozen checks We don't have to know whether we are transitioning or not. Because we will always freeze the value in single value mode. --- .../@headlessui-react/src/components/listbox/listbox.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 245df7a51e..7ff596d31c 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -591,7 +591,7 @@ function OptionsFn( // The moment we picked a value in single value mode, the value should be // frozen immediately. - let hasFrozenValue = useSlice(machine, machine.selectors.hasFrozenValue) && transition + let hasFrozenValue = useSlice(machine, machine.selectors.hasFrozenValue) // 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 @@ -599,8 +599,7 @@ function OptionsFn( // // When the `static` prop is used, we should never freeze, because rendering // is up to the user. - let shouldFreeze = - (hasFrozenValue || (visible && listboxState === ListboxStates.Closed)) && !props.static + let shouldFreeze = hasFrozenValue && !props.static // Frozen state, the selected value will only update visually when the user re-opens the let frozenValue = useFrozenData(shouldFreeze, data.value) From 09b1e86e3c88d911d55b83bf7549b473097d5b50 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 25 Sep 2025 15:02:10 +0200 Subject: [PATCH 6/7] merge checks --- .../@headlessui-react/src/components/listbox/listbox.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 7ff596d31c..d3af92f23d 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -589,17 +589,13 @@ function OptionsFn( // the panel whenever necessary. let panelEnabled = didButtonMove ? false : visible - // The moment we picked a value in single value mode, the value should be - // frozen immediately. - let hasFrozenValue = useSlice(machine, machine.selectors.hasFrozenValue) - // 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. // // When the `static` prop is used, we should never freeze, because rendering // is up to the user. - let shouldFreeze = hasFrozenValue && !props.static + let shouldFreeze = useSlice(machine, machine.selectors.hasFrozenValue) && !props.static // Frozen state, the selected value will only update visually when the user re-opens the let frozenValue = useFrozenData(shouldFreeze, data.value) From 0b887118bd2ec34d8b9d3190228af94e0e93d0d1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 25 Sep 2025 15:02:49 +0200 Subject: [PATCH 7/7] use `else if` Didn't do that initially because this exact block exists somewhere else, but the `else if` is a little bit cleaner syntax wise. --- .../src/components/listbox/listbox-machine.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts index 1a9b0fd0c8..fa91da11eb 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts +++ b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts @@ -592,11 +592,9 @@ export class ListboxMachine extends Machine, Actions> { if (this.state.activeOptionIndex !== null) { let { dataRef } = this.state.options[this.state.activeOptionIndex] this.actions.selectOption(dataRef.current.value) - } else { - if (this.state.dataRef.current.mode === ValueMode.Single) { - this.actions.closeListbox() - this.state.buttonElement?.focus({ preventScroll: true }) - } + } else if (this.state.dataRef.current.mode === ValueMode.Single) { + this.actions.closeListbox() + this.state.buttonElement?.focus({ preventScroll: true }) } },