Skip to content

Commit 000e0c0

Browse files
authored
Prevent unnecessary execution of the displayValue callback in the ComboboxInput component (#3048)
* memoize the `currentDisplayValue` This used to be re-executed every single render. This should typically not be an issue, but if you use non-deterministic code (E.g.: `Math.random`, `Date.now`, …) then it could result in incorrect values. Using `useMemo` allows us to only re-run it if the `data.value` or thte `displayValue` actually changes. * add test to verify `currentDisplayValue` is stable * update changelog
1 parent 834dbf4 commit 000e0c0

File tree

4 files changed

+92
-2
lines changed

4 files changed

+92
-2
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
- Forward `disabled` state to hidden inputs in form-like components ([#3004](https://github.com/tailwindlabs/headlessui/pull/3004))
2020
- Prefer incoming `data-*` attributes, over the ones set by Headless UI ([#3035](https://github.com/tailwindlabs/headlessui/pull/3035))
2121
- Respect `selectedIndex` for controlled `<Tab/>` components ([#3037](https://github.com/tailwindlabs/headlessui/pull/3037))
22+
- Prevent unnecessary execution of the `displayValue` callback in the `ComboboxInput` component ([#3048](https://github.com/tailwindlabs/headlessui/pull/3048))
2223

2324
### Changed
2425

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,53 @@ describe('Rendering', () => {
489489
})
490490
)
491491

492+
it(
493+
'should keep the defaultValue when the Combobox state changes',
494+
suppressConsoleLogs(async () => {
495+
let data = [
496+
{ id: 1, name: 'alice', label: 'Alice' },
497+
{ id: 2, name: 'bob', label: 'Bob' },
498+
{ id: 3, name: 'charlie', label: 'Charlie' },
499+
]
500+
501+
function Example() {
502+
let [person, setPerson] = useState(data[1])
503+
504+
return (
505+
<Combobox value={person} onChange={setPerson} name="assignee" by="id">
506+
<Combobox.Input displayValue={() => String(Math.random())} />
507+
<Combobox.Button />
508+
<Combobox.Options>
509+
{data.map((person) => (
510+
<Combobox.Option key={person.id} value={person}>
511+
{person.label}
512+
</Combobox.Option>
513+
))}
514+
</Combobox.Options>
515+
</Combobox>
516+
)
517+
}
518+
519+
render(<Example />)
520+
521+
let value = getComboboxInput()?.value
522+
523+
// Toggle the state a few times combobox
524+
await click(getComboboxButton())
525+
await click(getComboboxButton())
526+
await click(getComboboxButton())
527+
528+
// Verify the value is still the same
529+
expect(getComboboxInput()?.value).toBe(value)
530+
531+
// Choose an option, which should update the value
532+
await click(getComboboxOptions()[2])
533+
534+
// Verify the value changed
535+
expect(getComboboxInput()?.value).not.toBe(value)
536+
})
537+
)
538+
492539
it(
493540
'should close the Combobox when the input is blurred',
494541
suppressConsoleLogs(async () => {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,15 +1005,15 @@ function InputFn<
10051005
// which should always result in a string (since we are filling in the value of the text input),
10061006
// you don't have to use this at all, a more common UI is a "tag" based UI, which you can render
10071007
// yourself using the selected option(s).
1008-
let currentDisplayValue = (function () {
1008+
let currentDisplayValue = useMemo(() => {
10091009
if (typeof displayValue === 'function' && data.value !== undefined) {
10101010
return displayValue(data.value as unknown as TType) ?? ''
10111011
} else if (typeof data.value === 'string') {
10121012
return data.value
10131013
} else {
10141014
return ''
10151015
}
1016-
})()
1016+
}, [data.value, displayValue])
10171017

10181018
// Syncing the input value has some rules attached to it to guarantee a smooth and expected user
10191019
// experience:

packages/@headlessui-vue/src/components/combobox/combobox.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,48 @@ describe('Rendering', () => {
483483
)
484484
})
485485

486+
it(
487+
'should not crash when a defaultValue is not given',
488+
suppressConsoleLogs(async () => {
489+
let data = [
490+
{ id: 1, name: 'alice', label: 'Alice' },
491+
{ id: 2, name: 'bob', label: 'Bob' },
492+
{ id: 3, name: 'charlie', label: 'Charlie' },
493+
]
494+
495+
renderTemplate({
496+
template: html`
497+
<Combobox v-model="person" name="assignee" by="id">
498+
<ComboboxInput :displayValue="displayValue" />
499+
<ComboboxButton />
500+
<ComboboxOptions>
501+
<ComboboxOption v-for="person in data" :key="person.id" :value="person">
502+
{{ person.label }}
503+
</ComboboxOption>
504+
<ComboboxOptions>
505+
</Combobox>
506+
`,
507+
setup: () => ({ person: ref(data[0]), data, displayValue: () => String(Math.random()) }),
508+
})
509+
510+
let value = getComboboxInput()?.value
511+
512+
// Toggle the state a few times combobox
513+
await click(getComboboxButton())
514+
await click(getComboboxButton())
515+
await click(getComboboxButton())
516+
517+
// Verify the value is still the same
518+
expect(getComboboxInput()?.value).toBe(value)
519+
520+
// Choose an option, which should update the value
521+
await click(getComboboxOptions()[1])
522+
523+
// Verify the value changed
524+
expect(getComboboxInput()?.value).not.toBe(value)
525+
})
526+
)
527+
486528
it(
487529
'should not crash when a defaultValue is not given',
488530
suppressConsoleLogs(async () => {

0 commit comments

Comments
 (0)