Skip to content

Commit

Permalink
Only handle form reset when defaultValue is used (#3240)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
RobinMalfait committed May 24, 2024
1 parent c2754bc commit 8c3499c
Show file tree
Hide file tree
Showing 7 changed files with 51 additions and 14 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions packages/@headlessui-react/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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 (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -481,7 +482,7 @@ function ListboxFn<
let providedDisabled = useDisabled()
let {
value: controlledValue,
defaultValue,
defaultValue: _defaultValue,
form,
name,
onChange: controlledOnChange,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>[]
Expand All @@ -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(
Expand Down Expand Up @@ -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">
Expand Down
13 changes: 10 additions & 3 deletions packages/@headlessui-react/src/components/switch/switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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 (
<>
Expand Down
13 changes: 13 additions & 0 deletions packages/@headlessui-react/src/hooks/use-default-value.ts
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 8c3499c

Please sign in to comment.