Only handle form reset when defaultValue is used (#3240)
* 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. * 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. * update changelog
This commit is contained in:
@@ -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 `<PopoverPanel>` component moves focus inside (without using `<PortalGroup>`) ([#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
|
||||
|
||||
|
||||
@@ -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<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TTyp
|
||||
disabled = providedDisabled || false,
|
||||
autoFocus = false,
|
||||
checked: controlledChecked,
|
||||
defaultChecked = false,
|
||||
defaultChecked: _defaultChecked,
|
||||
onChange: controlledOnChange,
|
||||
name,
|
||||
value,
|
||||
@@ -94,7 +95,12 @@ function CheckboxFn<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TTyp
|
||||
...theirProps
|
||||
} = props
|
||||
|
||||
let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked)
|
||||
let defaultChecked = useDefaultValue(_defaultChecked)
|
||||
let [checked, onChange] = useControllable(
|
||||
controlledChecked,
|
||||
controlledOnChange,
|
||||
defaultChecked ?? false
|
||||
)
|
||||
|
||||
let labelledBy = useLabelledBy()
|
||||
let describedBy = useDescribedBy()
|
||||
@@ -166,8 +172,9 @@ function CheckboxFn<TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, TTyp
|
||||
}, [checked, indeterminate, disabled, hover, focus, active, changing, autoFocus])
|
||||
|
||||
let reset = useCallback(() => {
|
||||
if (defaultChecked === undefined) return
|
||||
return onChange?.(defaultChecked)
|
||||
}, [onChange /* Explicitly ignoring `defaultChecked` */])
|
||||
}, [onChange, defaultChecked])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
|
||||
let providedDisabled = useDisabled()
|
||||
let {
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
defaultValue: _defaultValue,
|
||||
onChange: controlledOnChange,
|
||||
form,
|
||||
name,
|
||||
@@ -651,6 +652,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
|
||||
nullable: _nullable,
|
||||
...theirProps
|
||||
} = props
|
||||
let defaultValue = useDefaultValue(_defaultValue)
|
||||
let [value = multiple ? [] : undefined, theirOnChange] = useControllable<any>(
|
||||
controlledValue,
|
||||
controlledOnChange,
|
||||
@@ -887,8 +889,9 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
|
||||
let ourProps = ref === null ? {} : { ref }
|
||||
|
||||
let reset = useCallback(() => {
|
||||
if (defaultValue === undefined) return
|
||||
return theirOnChange?.(defaultValue)
|
||||
}, [theirOnChange /* Explicitly ignoring `defaultValue` */])
|
||||
}, [theirOnChange, defaultValue])
|
||||
|
||||
return (
|
||||
<LabelProvider
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useActivePress } from '../../hooks/use-active-press'
|
||||
import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator'
|
||||
import { useComputed } from '../../hooks/use-computed'
|
||||
import { useControllable } from '../../hooks/use-controllable'
|
||||
import { useDefaultValue } from '../../hooks/use-default-value'
|
||||
import { useDidElementMove } from '../../hooks/use-did-element-move'
|
||||
import { useDisposables } from '../../hooks/use-disposables'
|
||||
import { useElementSize } from '../../hooks/use-element-size'
|
||||
@@ -481,7 +482,7 @@ function ListboxFn<
|
||||
let providedDisabled = useDisabled()
|
||||
let {
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
defaultValue: _defaultValue,
|
||||
form,
|
||||
name,
|
||||
onChange: controlledOnChange,
|
||||
@@ -493,9 +494,11 @@ function ListboxFn<
|
||||
__demoMode = false,
|
||||
...theirProps
|
||||
} = props
|
||||
|
||||
const orientation = horizontal ? 'horizontal' : 'vertical'
|
||||
let listboxRef = useSyncRefs(ref)
|
||||
|
||||
let defaultValue = useDefaultValue(_defaultValue)
|
||||
let [value = multiple ? [] : undefined, theirOnChange] = useControllable<any>(
|
||||
controlledValue,
|
||||
controlledOnChange,
|
||||
@@ -660,8 +663,9 @@ function ListboxFn<
|
||||
let ourProps = { ref: listboxRef }
|
||||
|
||||
let reset = useCallback(() => {
|
||||
if (defaultValue === undefined) return
|
||||
return theirOnChange?.(defaultValue)
|
||||
}, [theirOnChange /* Explicitly ignoring `defaultValue` */])
|
||||
}, [theirOnChange, defaultValue])
|
||||
|
||||
return (
|
||||
<LabelProvider
|
||||
|
||||
@@ -17,6 +17,7 @@ import React, {
|
||||
} from 'react'
|
||||
import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator'
|
||||
import { useControllable } from '../../hooks/use-controllable'
|
||||
import { useDefaultValue } from '../../hooks/use-default-value'
|
||||
import { useEvent } from '../../hooks/use-event'
|
||||
import { useId } from '../../hooks/use-id'
|
||||
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
|
||||
@@ -171,15 +172,14 @@ function RadioGroupFn<TTag extends ElementType = typeof DEFAULT_RADIO_GROUP_TAG,
|
||||
let {
|
||||
id = `headlessui-radiogroup-${internalId}`,
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
form,
|
||||
name,
|
||||
onChange: controlledOnChange,
|
||||
by,
|
||||
disabled = providedDisabled || false,
|
||||
defaultValue: _defaultValue,
|
||||
...theirProps
|
||||
} = props
|
||||
|
||||
let compare = useByComparator(by)
|
||||
let [state, dispatch] = useReducer(stateReducer, { options: [] } as StateDefinition<TType>)
|
||||
let options = state.options as Option<TType>[]
|
||||
@@ -188,6 +188,7 @@ function RadioGroupFn<TTag extends ElementType = typeof DEFAULT_RADIO_GROUP_TAG,
|
||||
let internalRadioGroupRef = useRef<HTMLElement | null>(null)
|
||||
let radioGroupRef = useSyncRefs(internalRadioGroupRef, ref)
|
||||
|
||||
let defaultValue = useDefaultValue(_defaultValue)
|
||||
let [value, onChange] = useControllable(controlledValue, controlledOnChange, defaultValue)
|
||||
|
||||
let firstOption = useMemo(
|
||||
@@ -304,8 +305,9 @@ function RadioGroupFn<TTag extends ElementType = typeof DEFAULT_RADIO_GROUP_TAG,
|
||||
let slot = useMemo(() => ({ value }) satisfies RadioGroupRenderPropArg<TType>, [value])
|
||||
|
||||
let reset = useCallback(() => {
|
||||
return triggerChange(defaultValue!)
|
||||
}, [triggerChange /* Explicitly ignoring `defaultValue` */])
|
||||
if (defaultValue === undefined) return
|
||||
return triggerChange(defaultValue)
|
||||
}, [triggerChange, defaultValue])
|
||||
|
||||
return (
|
||||
<DescriptionProvider name="RadioGroup.Description">
|
||||
|
||||
@@ -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<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
|
||||
id = providedId || `headlessui-switch-${internalId}`,
|
||||
disabled = providedDisabled || false,
|
||||
checked: controlledChecked,
|
||||
defaultChecked = false,
|
||||
defaultChecked: _defaultChecked,
|
||||
onChange: controlledOnChange,
|
||||
name,
|
||||
value,
|
||||
@@ -162,7 +163,12 @@ function SwitchFn<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
|
||||
groupContext === null ? null : groupContext.setSwitch
|
||||
)
|
||||
|
||||
let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked)
|
||||
let defaultChecked = useDefaultValue(_defaultChecked)
|
||||
let [checked, onChange] = useControllable(
|
||||
controlledChecked,
|
||||
controlledOnChange,
|
||||
defaultChecked ?? false
|
||||
)
|
||||
|
||||
let d = useDisposables()
|
||||
let [changing, setChanging] = useState(false)
|
||||
@@ -232,8 +238,9 @@ function SwitchFn<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
|
||||
)
|
||||
|
||||
let reset = useCallback(() => {
|
||||
if (defaultChecked === undefined) return
|
||||
return onChange?.(defaultChecked)
|
||||
}, [onChange /* Explicitly ignoring `defaultChecked` */])
|
||||
}, [onChange, defaultChecked])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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<T>(value: T) {
|
||||
let [defaultValue] = useState(value)
|
||||
return defaultValue
|
||||
}
|
||||
Reference in New Issue
Block a user