Skip to content

Commit da2fa94

Browse files
Freeze values as soon as possible (#3802)
This PR fixes an issue with the `Listbox` component where we didn't freeze the value soon enough. This happens when state lives in the parent, and is updated via an `onChange`. What is currently happening: 1. User clicks on a listbox option, this should do 3 things: 1. Call the `onChange` with the new value 2. Close the listbox 3. "Freeze" the value, so the old value is still showing while the listbox options are closing. The problem is that calling the `onChange` updates the value in the parent, and the component re-renders with the new value. At the time we freeze the value, we already received the new value so we are freezing the incorrect value. This causes a visual glitch. See reproduction: tailwindlabs/tailwind-plus-issues#1761 This PR fixes that by changing the order a little bit so we freeze the value as early as possible. So now, when the user clicks on an option, we trigger a `SelectOption` action. This will track whether we should freeze the value or not in state immediately. After that, we call the `onChange`, and then close the listbox. Since we know we want to freeze the value _before_ calling `onChange`, we can be sure we are freezing the correct (old) value. ## Test plan Made a little video but with a duration of 1000 instead of 100 so you can clearly see the old value and no visual jumps while the listbox is closing. https://github.com/user-attachments/assets/971b8ff4-2b03-4f6e-99af-f21f14d37930 Fixes: tailwindlabs/tailwind-plus-issues#1761 --------- Co-authored-by: Jordan Pittman <[email protected]>
1 parent 6b5709a commit da2fa94

File tree

4 files changed

+57
-25
lines changed

4 files changed

+57
-25
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Infer `Combobox` type based on `onChange` handler ([#3798](https://github.com/tailwindlabs/headlessui/pull/3798))
1616
- Allow home/end key default behavior inside `ComboboxInput` when `Combobox` is closed ([#3798](https://github.com/tailwindlabs/headlessui/pull/3798))
1717
- Ensure interacting with a `Dialog` on iOS works after interacting with a disallowed area ([#3801](https://github.com/tailwindlabs/headlessui/pull/3801))
18+
- Freeze Listbox values as soon as possible when closing ([#3802](https://github.com/tailwindlabs/headlessui/pull/3802))
1819

1920
## [2.2.8] - 2025-09-12
2021

packages/@headlessui-react/src/components/listbox/listbox-machine.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ interface State<T> {
6565
activeOptionIndex: number | null
6666
activationTrigger: ActivationTrigger
6767

68+
frozenValue: boolean
69+
6870
buttonElement: HTMLButtonElement | null
6971
optionsElement: HTMLElement | null
7072

@@ -82,6 +84,7 @@ export enum ActionTypes {
8284
GoToOption,
8385
Search,
8486
ClearSearch,
87+
SelectOption,
8588

8689
RegisterOptions,
8790
UnregisterOptions,
@@ -137,6 +140,7 @@ type Actions<T> =
137140
}
138141
| { type: ActionTypes.Search; value: string }
139142
| { type: ActionTypes.ClearSearch }
143+
| { type: ActionTypes.SelectOption; value: T }
140144
| {
141145
type: ActionTypes.RegisterOptions
142146
options: { id: string; dataRef: ListboxOptionDataRef<T> }[]
@@ -181,6 +185,7 @@ let reducers: {
181185

182186
return {
183187
...state,
188+
frozenValue: false,
184189
pendingFocus: action.focus,
185190
listboxState: ListboxStates.Open,
186191
activeOptionIndex,
@@ -338,6 +343,22 @@ let reducers: {
338343
if (state.searchQuery === '') return state
339344
return { ...state, searchQuery: '' }
340345
},
346+
[ActionTypes.SelectOption](state) {
347+
if (state.dataRef.current.mode === ValueMode.Single) {
348+
// The moment you select a value in single value mode, we want to close
349+
// the listbox and freeze the value to prevent UI flicker.
350+
return { ...state, frozenValue: true }
351+
}
352+
353+
// We have an event listener for `SelectOption`, but that will only be
354+
// called when the state changes. In multi-value mode we don't have a state
355+
// change but we still want to trigger the event listener. Therefore we
356+
// return a new object to trigger that event.
357+
//
358+
// Not the cleanest, but that's why we have this, instead of just returning
359+
// `state`.
360+
return { ...state }
361+
},
341362
[ActionTypes.RegisterOptions]: (state, action) => {
342363
let options = state.options.concat(action.options)
343364

@@ -436,6 +457,7 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
436457
optionsElement: null,
437458
pendingShouldSort: false,
438459
pendingFocus: { focus: Focus.Nothing },
460+
frozenValue: false,
439461
__demoMode,
440462
buttonPositionState: ElementPositionState.Idle,
441463
})
@@ -487,6 +509,15 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
487509
)
488510
})
489511
})
512+
513+
this.on(ActionTypes.SelectOption, (_, action) => {
514+
this.actions.onChange(action.value)
515+
516+
if (this.state.dataRef.current.mode === ValueMode.Single) {
517+
this.actions.closeListbox()
518+
this.state.buttonElement?.focus({ preventScroll: true })
519+
}
520+
})
490521
}
491522

492523
actions = {
@@ -556,22 +587,21 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
556587
) => {
557588
this.send({ type: ActionTypes.OpenListbox, focus })
558589
},
590+
559591
selectActiveOption: () => {
560592
if (this.state.activeOptionIndex !== null) {
561-
let { dataRef, id } = this.state.options[this.state.activeOptionIndex]
562-
this.actions.onChange(dataRef.current.value)
563-
564-
// It could happen that the `activeOptionIndex` stored in state is actually null,
565-
// but we are getting the fallback active option back instead.
566-
this.send({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
593+
let { dataRef } = this.state.options[this.state.activeOptionIndex]
594+
this.actions.selectOption(dataRef.current.value)
595+
} else if (this.state.dataRef.current.mode === ValueMode.Single) {
596+
this.actions.closeListbox()
597+
this.state.buttonElement?.focus({ preventScroll: true })
567598
}
568599
},
569-
selectOption: (id: string) => {
570-
let option = this.state.options.find((item) => item.id === id)
571-
if (!option) return
572600

573-
this.actions.onChange(option.dataRef.current.value)
601+
selectOption: (value: T) => {
602+
this.send({ type: ActionTypes.SelectOption, value })
574603
},
604+
575605
search: (value: string) => {
576606
this.send({ type: ActionTypes.Search, value })
577607
},
@@ -600,6 +630,10 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
600630
return activeOptionIndex !== null ? options[activeOptionIndex]?.id === id : false
601631
},
602632

633+
hasFrozenValue(state: State<T>) {
634+
return state.frozenValue
635+
},
636+
603637
shouldScrollIntoView(state: State<T>, id: string) {
604638
if (state.__demoMode) return false
605639
if (state.listboxState !== ListboxStates.Open) return false

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
595595
//
596596
// When the `static` prop is used, we should never freeze, because rendering
597597
// is up to the user.
598-
let shouldFreeze = visible && listboxState === ListboxStates.Closed && !props.static
598+
let shouldFreeze = useSlice(machine, machine.selectors.hasFrozenValue) && !props.static
599599

600600
// Frozen state, the selected value will only update visually when the user re-opens the <Listbox />
601601
let frozenValue = useFrozenData(shouldFreeze, data.value)
@@ -671,14 +671,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
671671
event.preventDefault()
672672
event.stopPropagation()
673673

674-
if (machine.state.activeOptionIndex !== null) {
675-
let { dataRef } = machine.state.options[machine.state.activeOptionIndex]
676-
machine.actions.onChange(dataRef.current.value)
677-
}
678-
if (data.mode === ValueMode.Single) {
679-
flushSync(() => machine.actions.closeListbox())
680-
machine.state.buttonElement?.focus({ preventScroll: true })
681-
}
674+
machine.actions.selectActiveOption()
682675
break
683676

684677
case match(data.orientation, {
@@ -872,11 +865,7 @@ function OptionFn<
872865

873866
let handleClick = useEvent((event: { preventDefault: Function }) => {
874867
if (disabled) return event.preventDefault()
875-
machine.actions.onChange(value)
876-
if (data.mode === ValueMode.Single) {
877-
flushSync(() => machine.actions.closeListbox())
878-
machine.state.buttonElement?.focus({ preventScroll: true })
879-
}
868+
machine.actions.selectOption(value)
880869
})
881870

882871
let handleFocus = useEvent(() => {

packages/@headlessui-react/src/hooks/use-transition.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export function useTransition(
165165
},
166166
done() {
167167
if (cancelledRef.current) {
168-
if (typeof element.getAnimations === 'function' && element.getAnimations().length > 0) {
168+
if (hasPendingTransitions(element)) {
169169
return
170170
}
171171
}
@@ -304,3 +304,11 @@ function prepareTransition(
304304
// Reset the transition to what it was before
305305
node.style.transition = previous
306306
}
307+
308+
function hasPendingTransitions(node: HTMLElement) {
309+
let animations = node.getAnimations?.() ?? []
310+
311+
return animations.some((animation) => {
312+
return animation instanceof CSSTransition && animation.playState !== 'finished'
313+
})
314+
}

0 commit comments

Comments
 (0)