Skip to content

Commit f67e449

Browse files
authored
Ensure changing the immediate prop on the Combobox component is reflected in the component (#3792)
This PR fixes an issue where the changing the `immediate` prop value on the `Combobox` component didn't always reflect in the expected behavior, but only after other state changed. This was a `useMemo` dependency issue. I also added a new `useSlot` internal hook that ensures the value and dependency array is always up to date since we don't really care about optimizing the slot values but we just want a stable reference back instead. Fixes: #3659
1 parent a2177d8 commit f67e449

File tree

22 files changed

+236
-294
lines changed

22 files changed

+236
-294
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- Ensure `element` in `ref` callback is always connected when rendering in a `Portal` ([#3789](https://github.com/tailwindlabs/headlessui/pull/3789))
2020
- Ensure form state is up to date when using uncontrolled components ([#3790](https://github.com/tailwindlabs/headlessui/pull/3790))
2121
- Ensure `data-open` on `ComboboxInput` is up to date ([#3791](https://github.com/tailwindlabs/headlessui/pull/3791))
22+
- Ensure changing the `immediate` prop value on the `Combobox` component works as expected ([#3792](https://github.com/tailwindlabs/headlessui/pull/3792))
2223

2324
## [2.2.7] - 2025-07-30
2425

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import { useFocusRing } from '@react-aria/focus'
44
import { useHover } from '@react-aria/interactions'
5-
import { useMemo, type ElementType, type Ref } from 'react'
5+
import { type ElementType, type Ref } from 'react'
66
import { useActivePress } from '../../hooks/use-active-press'
7+
import { useSlot } from '../../hooks/use-slot'
78
import { useDisabled } from '../../internal/disabled'
89
import type { Props } from '../../types'
910
import {
@@ -59,9 +60,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
5960
pressProps
6061
)
6162

62-
let slot = useMemo(() => {
63-
return { disabled, hover, focus, active, autofocus: autoFocus } satisfies ButtonRenderPropArg
64-
}, [disabled, hover, focus, active, autoFocus])
63+
let slot = useSlot<ButtonRenderPropArg>({ disabled, hover, focus, active, autofocus: autoFocus })
6564

6665
let render = useRender()
6766

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useFocusRing } from '@react-aria/focus'
44
import { useHover } from '@react-aria/interactions'
55
import React, {
66
useCallback,
7-
useMemo,
87
useState,
98
type ElementType,
109
type KeyboardEvent as ReactKeyboardEvent,
@@ -17,6 +16,7 @@ import { useDefaultValue } from '../../hooks/use-default-value'
1716
import { useDisposables } from '../../hooks/use-disposables'
1817
import { useEvent } from '../../hooks/use-event'
1918
import { useId } from '../../hooks/use-id'
19+
import { useSlot } from '../../hooks/use-slot'
2020
import { useDisabled } from '../../internal/disabled'
2121
import { FormFields } from '../../internal/form-fields'
2222
import { useProvidedId } from '../../internal/id'
@@ -159,18 +159,16 @@ function CheckboxFn<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TTyp
159159
pressProps
160160
)
161161

162-
let slot = useMemo(() => {
163-
return {
164-
checked,
165-
disabled,
166-
hover,
167-
focus,
168-
active,
169-
indeterminate,
170-
changing,
171-
autofocus: autoFocus,
172-
} satisfies CheckboxRenderPropArg
173-
}, [checked, indeterminate, disabled, hover, focus, active, changing, autoFocus])
162+
let slot = useSlot<CheckboxRenderPropArg>({
163+
checked,
164+
disabled,
165+
hover,
166+
focus,
167+
active,
168+
indeterminate,
169+
changing,
170+
autofocus: autoFocus,
171+
})
174172

175173
let reset = useCallback(() => {
176174
if (defaultChecked === undefined) return

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

Lines changed: 43 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-q
3939
import { useRefocusableInput } from '../../hooks/use-refocusable-input'
4040
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
4141
import { useScrollLock } from '../../hooks/use-scroll-lock'
42+
import { useSlot } from '../../hooks/use-slot'
4243
import { useSyncRefs } from '../../hooks/use-sync-refs'
4344
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
4445
import { transitionDataAttributes, useTransition } from '../../hooks/use-transition'
@@ -369,17 +370,20 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
369370
onClose,
370371
}),
371372
[
373+
__demoMode,
374+
immediate,
375+
optionsPropsRef,
372376
value,
373377
defaultValue,
374378
disabled,
375379
invalid,
376380
multiple,
377-
theirOnChange,
378-
isSelected,
379-
__demoMode,
380-
machine,
381381
virtual,
382382
virtualSlice,
383+
theirOnChange,
384+
isSelected,
385+
calculateIndex,
386+
compare,
383387
onClose,
384388
]
385389
)
@@ -418,16 +422,14 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
418422
let activeOptionIndex = useSlice(machine, machine.selectors.activeOptionIndex)
419423
let activeOption = useSlice(machine, machine.selectors.activeOption)
420424

421-
let slot = useMemo(() => {
422-
return {
423-
open: comboboxState === ComboboxState.Open,
424-
disabled,
425-
invalid,
426-
activeIndex: activeOptionIndex,
427-
activeOption,
428-
value,
429-
} satisfies ComboboxRenderPropArg<unknown>
430-
}, [data, disabled, value, invalid, activeOption, comboboxState])
425+
let slot = useSlot<ComboboxRenderPropArg<unknown>>({
426+
open: comboboxState === ComboboxState.Open,
427+
disabled,
428+
invalid,
429+
activeIndex: activeOptionIndex,
430+
activeOption,
431+
value,
432+
})
431433

432434
let [labelledby, LabelProvider] = useLabels()
433435

@@ -903,12 +905,14 @@ function InputFn<
903905

904906
let optionsElement = useSlice(machine, (state) => state.optionsElement)
905907

906-
let open = comboboxState === ComboboxState.Open
907-
let invalid = data.invalid
908-
let autofocus = autoFocus
909-
let slot = useMemo(() => {
910-
return { open, disabled, invalid, hover, focus, autofocus } satisfies InputRenderPropArg
911-
}, [open, disabled, invalid, hover, focus, autofocus])
908+
let slot = useSlot<InputRenderPropArg>({
909+
open: comboboxState === ComboboxState.Open,
910+
disabled,
911+
invalid: data.invalid,
912+
hover,
913+
focus,
914+
autofocus: autoFocus,
915+
})
912916

913917
let ourProps = mergeProps(
914918
{
@@ -1116,17 +1120,15 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
11161120
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
11171121
let { pressed: active, pressProps } = useActivePress({ disabled })
11181122

1119-
let slot = useMemo(() => {
1120-
return {
1121-
open: comboboxState === ComboboxState.Open,
1122-
active: active || comboboxState === ComboboxState.Open,
1123-
disabled,
1124-
invalid: data.invalid,
1125-
value: data.value,
1126-
hover,
1127-
focus,
1128-
} satisfies ButtonRenderPropArg
1129-
}, [data, hover, focus, active, disabled, comboboxState])
1123+
let slot = useSlot<ButtonRenderPropArg>({
1124+
open: comboboxState === ComboboxState.Open,
1125+
active: active || comboboxState === ComboboxState.Open,
1126+
disabled,
1127+
invalid: data.invalid,
1128+
value: data.value,
1129+
hover,
1130+
focus,
1131+
})
11301132
let ourProps = mergeProps(
11311133
{
11321134
ref: buttonRef,
@@ -1294,12 +1296,10 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
12941296

12951297
let labelledBy = useLabelledBy([buttonElement?.id])
12961298

1297-
let slot = useMemo(() => {
1298-
return {
1299-
open: comboboxState === ComboboxState.Open,
1300-
option: undefined,
1301-
} satisfies OptionsRenderPropArg
1302-
}, [comboboxState])
1299+
let slot = useSlot<OptionsRenderPropArg>({
1300+
open: comboboxState === ComboboxState.Open,
1301+
option: undefined,
1302+
})
13031303

13041304
// When the user scrolls **using the mouse** (so scroll event isn't appropriate)
13051305
// we want to make sure that the current activation trigger is set to pointer.
@@ -1580,14 +1580,12 @@ function OptionFn<
15801580
machine.actions.goToOption({ focus: Focus.Nothing })
15811581
})
15821582

1583-
let slot = useMemo(() => {
1584-
return {
1585-
active,
1586-
focus: active,
1587-
selected,
1588-
disabled,
1589-
} satisfies OptionRenderPropArg
1590-
}, [active, selected, disabled])
1583+
let slot = useSlot<OptionRenderPropArg>({
1584+
active,
1585+
focus: active,
1586+
selected,
1587+
disabled,
1588+
})
15911589

15921590
let ourProps = {
15931591
id,

packages/@headlessui-react/src/components/data-interactive/data-interactive.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import { useFocusRing } from '@react-aria/focus'
44
import { useHover } from '@react-aria/interactions'
5-
import { Fragment, useMemo, type ElementType, type Ref } from 'react'
5+
import { Fragment, type ElementType, type Ref } from 'react'
66
import { useActivePress } from '../../hooks/use-active-press'
7+
import { useSlot } from '../../hooks/use-slot'
78
import type { Props } from '../../types'
89
import {
910
forwardRefWithAs,
@@ -42,10 +43,7 @@ function DataInteractiveFn<TTag extends ElementType = typeof DEFAULT_DATA_INTERA
4243

4344
let ourProps = mergeProps({ ref }, focusProps, hoverProps, pressProps)
4445

45-
let slot = useMemo(
46-
() => ({ hover, focus, active }) satisfies DataInteractiveRenderPropArg,
47-
[hover, focus, active]
48-
)
46+
let slot = useSlot<DataInteractiveRenderPropArg>({ hover, focus, active })
4947

5048
let render = useRender()
5149

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import React, {
1212
import { useEvent } from '../../hooks/use-event'
1313
import { useId } from '../../hooks/use-id'
1414
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
15+
import { useSlot } from '../../hooks/use-slot'
1516
import { useSyncRefs } from '../../hooks/use-sync-refs'
1617
import { useDisabled } from '../../internal/disabled'
1718
import type { Props } from '../../types'
@@ -117,8 +118,7 @@ function DescriptionFn<TTag extends ElementType = typeof DEFAULT_DESCRIPTION_TAG
117118

118119
useIsoMorphicEffect(() => context.register(id), [id, context.register])
119120

120-
let disabled = providedDisabled || false
121-
let slot = useMemo(() => ({ ...context.slot, disabled }), [context.slot, disabled])
121+
let slot = useSlot({ ...context.slot, disabled: providedDisabled || false })
122122
let ourProps = { ref: descriptionRef, ...context.props, id }
123123

124124
let render = useRender()

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

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
} from '../../hooks/use-root-containers'
3535
import { useScrollLock } from '../../hooks/use-scroll-lock'
3636
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
37+
import { useSlot } from '../../hooks/use-slot'
3738
import { useSyncRefs } from '../../hooks/use-sync-refs'
3839
import { CloseProvider } from '../../internal/close-provider'
3940
import { ResetOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
@@ -274,13 +275,10 @@ let InternalDialog = forwardRefWithAs(function InternalDialog<
274275

275276
let contextBag = useMemo<ContextType<typeof DialogContext>>(
276277
() => [{ dialogState, close, setTitleId, unmount }, state],
277-
[dialogState, state, close, setTitleId, unmount]
278+
[dialogState, close, setTitleId, unmount, state]
278279
)
279280

280-
let slot = useMemo(
281-
() => ({ open: dialogState === DialogStates.Open }) satisfies DialogRenderPropArg,
282-
[dialogState]
283-
)
281+
let slot = useSlot<DialogRenderPropArg>({ open: dialogState === DialogStates.Open })
284282

285283
let ourProps = {
286284
ref: dialogRef,
@@ -455,10 +453,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
455453
let [{ dialogState, unmount }, state] = useDialogContext('Dialog.Panel')
456454
let panelRef = useSyncRefs(ref, state.panelRef)
457455

458-
let slot = useMemo(
459-
() => ({ open: dialogState === DialogStates.Open }) satisfies PanelRenderPropArg,
460-
[dialogState]
461-
)
456+
let slot = useSlot<PanelRenderPropArg>({ open: dialogState === DialogStates.Open })
462457

463458
// Prevent the click events inside the Dialog.Panel from bubbling through the React Tree which
464459
// could submit wrapping <form> elements even if we portalled the Dialog.
@@ -511,10 +506,7 @@ function BackdropFn<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG>(
511506
let { transition = false, ...theirProps } = props
512507
let [{ dialogState, unmount }] = useDialogContext('Dialog.Backdrop')
513508

514-
let slot = useMemo(
515-
() => ({ open: dialogState === DialogStates.Open }) satisfies BackdropRenderPropArg,
516-
[dialogState]
517-
)
509+
let slot = useSlot<BackdropRenderPropArg>({ open: dialogState === DialogStates.Open })
518510

519511
let ourProps = { ref, 'aria-hidden': true }
520512

@@ -563,10 +555,7 @@ function TitleFn<TTag extends ElementType = typeof DEFAULT_TITLE_TAG>(
563555
return () => setTitleId(null)
564556
}, [id, setTitleId])
565557

566-
let slot = useMemo(
567-
() => ({ open: dialogState === DialogStates.Open }) satisfies TitleRenderPropArg,
568-
[dialogState]
569-
)
558+
let slot = useSlot<TitleRenderPropArg>({ open: dialogState === DialogStates.Open })
570559

571560
let ourProps = { ref: titleRef, id }
572561

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

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useActivePress } from '../../hooks/use-active-press'
2424
import { useEvent } from '../../hooks/use-event'
2525
import { useId } from '../../hooks/use-id'
2626
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
27+
import { useSlot } from '../../hooks/use-slot'
2728
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
2829
import { transitionDataAttributes, useTransition } from '../../hooks/use-transition'
2930
import { CloseProvider } from '../../internal/close-provider'
@@ -225,12 +226,10 @@ function DisclosureFn<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
225226

226227
let api = useMemo<ContextType<typeof DisclosureAPIContext>>(() => ({ close }), [close])
227228

228-
let slot = useMemo(() => {
229-
return {
230-
open: disclosureState === DisclosureStates.Open,
231-
close,
232-
} satisfies DisclosureRenderPropArg
233-
}, [disclosureState, close])
229+
let slot = useSlot<DisclosureRenderPropArg>({
230+
open: disclosureState === DisclosureStates.Open,
231+
close,
232+
})
234233

235234
let ourProps = {
236235
ref: disclosureRef,
@@ -371,16 +370,14 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
371370
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
372371
let { pressed: active, pressProps } = useActivePress({ disabled })
373372

374-
let slot = useMemo(() => {
375-
return {
376-
open: state.disclosureState === DisclosureStates.Open,
377-
hover,
378-
active,
379-
disabled,
380-
focus,
381-
autofocus: autoFocus,
382-
} satisfies ButtonRenderPropArg
383-
}, [state, hover, active, focus, disabled, autoFocus])
373+
let slot = useSlot<ButtonRenderPropArg>({
374+
open: state.disclosureState === DisclosureStates.Open,
375+
hover,
376+
active,
377+
disabled,
378+
focus,
379+
autofocus: autoFocus,
380+
})
384381

385382
let type = useResolveButtonType(props, state.buttonElement)
386383
let ourProps = isWithinPanel
@@ -487,12 +484,10 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
487484
: state.disclosureState === DisclosureStates.Open
488485
)
489486

490-
let slot = useMemo(() => {
491-
return {
492-
open: state.disclosureState === DisclosureStates.Open,
493-
close,
494-
} satisfies PanelRenderPropArg
495-
}, [state.disclosureState, close])
487+
let slot = useSlot<PanelRenderPropArg>({
488+
open: state.disclosureState === DisclosureStates.Open,
489+
close,
490+
})
496491

497492
let ourProps = {
498493
ref: panelRef,

0 commit comments

Comments
 (0)