From 8a7d61a8b70d2c20efcde2feba424382b47032c2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 20 May 2024 20:48:59 +0200 Subject: [PATCH 1/3] add `useDefaultValue` hook This allows us to have a guaranteed `default value` that never changes unless the component re-mounts. Since the hook returns a stable value, we can safely include it in dependency arrays of certain hooks. Before this change, including this is in the dependency arrays it would cause a trigger or change of the hook when the `defaultValue` changes but we never want that. --- .../src/components/checkbox/checkbox.tsx | 6 ++++-- .../src/components/combobox/combobox.tsx | 6 ++++-- .../src/components/listbox/listbox.tsx | 7 +++++-- .../src/components/radio-group/radio-group.tsx | 7 ++++--- .../src/components/switch/switch.tsx | 6 ++++-- .../src/hooks/use-default-value.ts | 13 +++++++++++++ 6 files changed, 34 insertions(+), 11 deletions(-) create mode 100644 packages/@headlessui-react/src/hooks/use-default-value.ts diff --git a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx index a8d743ac07..468ca69ed1 100644 --- a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx +++ b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx @@ -13,6 +13,7 @@ import React, { } from 'react' import { useActivePress } from '../../hooks/use-active-press' import { useControllable } from '../../hooks/use-controllable' +import { useDefaultValue } from '../../hooks/use-default-value' import { useDisposables } from '../../hooks/use-disposables' import { useEvent } from '../../hooks/use-event' import { useId } from '../../hooks/use-id' @@ -85,7 +86,7 @@ function CheckboxFn { return onChange?.(defaultChecked) - }, [onChange /* Explicitly ignoring `defaultChecked` */]) + }, [onChange, defaultChecked]) return ( <> diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 7ff520d2c1..3be91875a3 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -24,6 +24,7 @@ import React, { import { useActivePress } from '../../hooks/use-active-press' import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator' import { useControllable } from '../../hooks/use-controllable' +import { useDefaultValue } from '../../hooks/use-default-value' import { useDisposables } from '../../hooks/use-disposables' import { useElementSize } from '../../hooks/use-element-size' import { useEvent } from '../../hooks/use-event' @@ -635,7 +636,7 @@ function ComboboxFn( controlledValue, controlledOnChange, @@ -888,7 +890,7 @@ function ComboboxFn { return theirOnChange?.(defaultValue) - }, [theirOnChange /* Explicitly ignoring `defaultValue` */]) + }, [theirOnChange, defaultValue]) return ( ( controlledValue, controlledOnChange, @@ -661,7 +664,7 @@ function ListboxFn< let reset = useCallback(() => { return theirOnChange?.(defaultValue) - }, [theirOnChange /* Explicitly ignoring `defaultValue` */]) + }, [theirOnChange, defaultValue]) return ( ) let options = state.options as Option[] @@ -188,6 +188,7 @@ function RadioGroupFn(null) let radioGroupRef = useSyncRefs(internalRadioGroupRef, ref) + let defaultValue = useDefaultValue(_defaultValue) let [value, onChange] = useControllable(controlledValue, controlledOnChange, defaultValue) let firstOption = useMemo( @@ -305,7 +306,7 @@ function RadioGroupFn { return triggerChange(defaultValue!) - }, [triggerChange /* Explicitly ignoring `defaultValue` */]) + }, [triggerChange, defaultValue]) return ( diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx index e1080faded..ec2120704c 100644 --- a/packages/@headlessui-react/src/components/switch/switch.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.tsx @@ -17,6 +17,7 @@ import React, { } from 'react' import { useActivePress } from '../../hooks/use-active-press' import { useControllable } from '../../hooks/use-controllable' +import { useDefaultValue } from '../../hooks/use-default-value' import { useDisposables } from '../../hooks/use-disposables' import { useEvent } from '../../hooks/use-event' import { useId } from '../../hooks/use-id' @@ -146,7 +147,7 @@ function SwitchFn( id = providedId || `headlessui-switch-${internalId}`, disabled = providedDisabled || false, checked: controlledChecked, - defaultChecked = false, + defaultChecked: _defaultChecked = false, onChange: controlledOnChange, name, value, @@ -162,6 +163,7 @@ function SwitchFn( groupContext === null ? null : groupContext.setSwitch ) + let defaultChecked = useDefaultValue(_defaultChecked) let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked) let d = useDisposables() @@ -233,7 +235,7 @@ function SwitchFn( let reset = useCallback(() => { return onChange?.(defaultChecked) - }, [onChange /* Explicitly ignoring `defaultChecked` */]) + }, [onChange, defaultChecked]) return ( <> diff --git a/packages/@headlessui-react/src/hooks/use-default-value.ts b/packages/@headlessui-react/src/hooks/use-default-value.ts new file mode 100644 index 0000000000..b7a3ada37d --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-default-value.ts @@ -0,0 +1,13 @@ +import { useState } from 'react' + +/** + * Returns a stable value that never changes unless the component is re-mounted. + * + * This ensures that we can use this value in a dependency array without causing + * unnecessary re-renders (because while the incoming `value` can change, the + * returned `defaultValue` won't change). + */ +export function useDefaultValue(value: T) { + let [defaultValue] = useState(value) + return defaultValue +} From 65df304dfde60d25e08151d838e2f9d3e47538c5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 24 May 2024 23:53:39 +0200 Subject: [PATCH 2/3] do not handle `reset` when no `defaultValue` or `defaultChecked` was provided If a `defaultValue` is provided, then the reset will be handled and the `onChange` will be called with this value. If no `defaultValue` was provided, we won't handle the `reset`, otherwise we would call the `onChange` with `undefined` which is incorrect. --- .../src/components/checkbox/checkbox.tsx | 9 +++++++-- .../src/components/combobox/combobox.tsx | 1 + .../@headlessui-react/src/components/listbox/listbox.tsx | 1 + .../src/components/radio-group/radio-group.tsx | 3 ++- .../@headlessui-react/src/components/switch/switch.tsx | 9 +++++++-- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx index 468ca69ed1..2c7e22a288 100644 --- a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx +++ b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx @@ -86,7 +86,7 @@ function CheckboxFn { + if (defaultChecked === undefined) return return onChange?.(defaultChecked) }, [onChange, defaultChecked]) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 3be91875a3..b5828b2c09 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -889,6 +889,7 @@ function ComboboxFn { + if (defaultValue === undefined) return return theirOnChange?.(defaultValue) }, [theirOnChange, defaultValue]) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 37e6a01bed..9f9ac41940 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -663,6 +663,7 @@ function ListboxFn< let ourProps = { ref: listboxRef } let reset = useCallback(() => { + if (defaultValue === undefined) return return theirOnChange?.(defaultValue) }, [theirOnChange, defaultValue]) diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx index af617f80e5..7c2130a803 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -305,7 +305,8 @@ function RadioGroupFn ({ value }) satisfies RadioGroupRenderPropArg, [value]) let reset = useCallback(() => { - return triggerChange(defaultValue!) + if (defaultValue === undefined) return + return triggerChange(defaultValue) }, [triggerChange, defaultValue]) return ( diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx index ec2120704c..5a17ce0525 100644 --- a/packages/@headlessui-react/src/components/switch/switch.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.tsx @@ -147,7 +147,7 @@ function SwitchFn( id = providedId || `headlessui-switch-${internalId}`, disabled = providedDisabled || false, checked: controlledChecked, - defaultChecked: _defaultChecked = false, + defaultChecked: _defaultChecked, onChange: controlledOnChange, name, value, @@ -164,7 +164,11 @@ function SwitchFn( ) let defaultChecked = useDefaultValue(_defaultChecked) - let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked) + let [checked, onChange] = useControllable( + controlledChecked, + controlledOnChange, + defaultChecked ?? false + ) let d = useDisposables() let [changing, setChanging] = useState(false) @@ -234,6 +238,7 @@ function SwitchFn( ) let reset = useCallback(() => { + if (defaultChecked === undefined) return return onChange?.(defaultChecked) }, [onChange, defaultChecked]) From 167dbed90802c991b2cb689c787c80b19a4b2f30 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sat, 25 May 2024 00:01:09 +0200 Subject: [PATCH 3/3] 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 1f6c34774c..52b3be2e45 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure page doesn't scroll down when pressing `Escape` to close the `Dialog` component ([#3218](https://github.com/tailwindlabs/headlessui/pull/3218)) - Fix crash when toggling between `virtual` and non-virtual mode in `Combobox` component ([#3236](https://github.com/tailwindlabs/headlessui/pull/3236)) - Ensure tabbing to a portalled `` component moves focus inside (without using ``) ([#3239](https://github.com/tailwindlabs/headlessui/pull/3239)) +- Only handle form reset when `defaultValue` is used ([#3240](https://github.com/tailwindlabs/headlessui/pull/3240)) ### Deprecated