Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitda2fa94

Browse files
Freeze values as soon as possible (#3802)
This PR fixes an issue with the `Listbox` component where we didn'tfreeze 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 listbox3. "Freeze" the value, so the old value is still showing while thelistbox options are closing.The problem is that calling the `onChange` updates the value in theparent, and the component re-renders with the new value. At the time wefreeze the value, we already received the new value so we are freezingthe incorrect value. This causes a visual glitch. See reproduction:tailwindlabs/tailwind-plus-issues#1761This PR fixes that by changing the order a little bit so we freeze thevalue 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 instate immediately. After that, we call the `onChange`, and then closethe 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 planMade a little video but with a duration of 1000 instead of 100 so youcan clearly see the old value and no visual jumps while the listbox isclosing.https://github.com/user-attachments/assets/971b8ff4-2b03-4f6e-99af-f21f14d37930Fixes:tailwindlabs/tailwind-plus-issues#1761---------Co-authored-by: Jordan Pittman <jordan@cryptica.me>
1 parent6b5709a commitda2fa94

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==='')returnstate
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
letoptions=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+
}elseif(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-
letoption=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
returnactiveOptionIndex!==null ?options[activeOptionIndex]?.id===id :false
601631
},
602632

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

‎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-
letshouldFreeze=visible&&listboxState===ListboxStates.Closed&&!props.static
598+
letshouldFreeze=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
letfrozenValue=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
casematch(data.orientation,{
@@ -872,11 +865,7 @@ function OptionFn<
872865

873866
lethandleClick=useEvent((event:{preventDefault:Function})=>{
874867
if(disabled)returnevent.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
lethandleFocus=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(typeofelement.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+
functionhasPendingTransitions(node:HTMLElement){
309+
letanimations=node.getAnimations?.()??[]
310+
311+
returnanimations.some((animation)=>{
312+
returnanimationinstanceofCSSTransition&&animation.playState!=='finished'
313+
})
314+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp