From a4953a2b11e61e3c8ef9be8b03add7f75969bd5a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 9 Oct 2024 23:25:17 +0200 Subject: [PATCH] Fix crash in `ListboxOptions` when using `as={Fragment}` (#3513) This PR fixes an issue where a `Maximum update depth exceeded` error occurs if you use `as={Fragment}` in the `ListboxOptions` component. This PR also includes a refactor to make sure this exact issue cannot happen anymore in other components. Fixes: #3507 --- packages/@headlessui-react/CHANGELOG.md | 1 + .../src/components/button/button.tsx | 7 +++---- .../src/components/checkbox/checkbox.tsx | 4 +++- .../src/components/combobox/combobox.tsx | 15 +++++++++++---- .../data-interactive/data-interactive.tsx | 4 +++- .../components/description/description.tsx | 4 +++- .../src/components/dialog/dialog.tsx | 10 +++++++++- .../src/components/disclosure/disclosure.tsx | 13 +++++++------ .../src/components/field/field.tsx | 4 +++- .../src/components/fieldset/fieldset.tsx | 4 +++- .../src/components/focus-trap/focus-trap.tsx | 4 +++- .../src/components/input/input.tsx | 4 +++- .../src/components/label/label.tsx | 4 +++- .../src/components/listbox/listbox.tsx | 15 +++++++++++---- .../src/components/menu/menu.tsx | 19 +++++++++++++++---- .../src/components/popover/popover.tsx | 15 +++++++++++---- .../src/components/portal/portal.tsx | 8 +++++++- .../components/radio-group/radio-group.tsx | 8 +++++++- .../src/components/select/select.tsx | 4 +++- .../src/components/switch/switch.tsx | 6 +++++- .../src/components/tabs/tabs.tsx | 12 +++++++++++- .../src/components/textarea/textarea.tsx | 4 +++- .../src/components/tooltip/tooltip.tsx | 8 +++++++- .../src/components/transition/transition.tsx | 6 +++++- .../@headlessui-react/src/internal/hidden.tsx | 4 +++- .../src/utils/render.test.tsx | 10 +++++++++- .../@headlessui-react/src/utils/render.ts | 15 ++++++++++++--- 27 files changed, 164 insertions(+), 48 deletions(-) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 4ff64b2bd7..e9a09c9fa0 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Use `React.JSX` instead of deprecated global `JSX` ([#3511](https://github.com/tailwindlabs/headlessui/pull/3511)) +- Fix crash in `ListboxOptions` when using `as={Fragment}` ([#3513](https://github.com/tailwindlabs/headlessui/pull/3513)) ## [2.1.9] - 2024-10-03 diff --git a/packages/@headlessui-react/src/components/button/button.tsx b/packages/@headlessui-react/src/components/button/button.tsx index 174b6a503e..2ead53dffa 100644 --- a/packages/@headlessui-react/src/components/button/button.tsx +++ b/packages/@headlessui-react/src/components/button/button.tsx @@ -9,8 +9,7 @@ import type { Props } from '../../types' import { forwardRefWithAs, mergeProps, - render, - useMergeRefsFn, + useRender, type HasDisplayName, type RefProp, } from '../../utils/render' @@ -42,7 +41,6 @@ function ButtonFn( ref: Ref ) { let providedDisabled = useDisabled() - let mergeRefs = useMergeRefsFn() let { disabled = providedDisabled || false, autoFocus = false, ...theirProps } = props let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) @@ -65,8 +63,9 @@ function ButtonFn( return { disabled, hover, focus, active, autofocus: autoFocus } satisfies ButtonRenderPropArg }, [disabled, hover, focus, active, autoFocus]) + let render = useRender() + return render({ - mergeRefs, ourProps, theirProps, slot, diff --git a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx index f68ed348fd..c964ff5844 100644 --- a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx +++ b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx @@ -26,7 +26,7 @@ import { attemptSubmit } from '../../utils/form' import { forwardRefWithAs, mergeProps, - render, + useRender, type HasDisplayName, type RefProp, } from '../../utils/render' @@ -176,6 +176,8 @@ function CheckboxFn {name != null && ( diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index b6ab9e1711..8c395f9440 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -69,8 +69,7 @@ import { RenderFeatures, forwardRefWithAs, mergeProps, - render, - useMergeRefsFn, + useRender, type HasDisplayName, type PropsForFeatures, type RefProp, @@ -949,6 +948,8 @@ function ComboboxFn( let data = useData('Combobox.Button') let actions = useActions('Combobox.Button') let buttonRef = useSyncRefs(ref, actions.setButtonElement) - let mergeRefs = useMergeRefsFn() let internalId = useId() let { @@ -1610,8 +1612,9 @@ function ButtonFn( pressProps ) + let render = useRender() + return render({ - mergeRefs, ourProps, theirProps, slot, @@ -1813,6 +1816,8 @@ function OptionsFn( }) } + let render = useRender() + return ( ({ ...context.slot, disabled }), [context.slot, disabled]) let ourProps = { ref: descriptionRef, ...context.props, id } + let render = useRender() + return render({ ourProps, theirProps, diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index f4e965b833..590660ee56 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -41,7 +41,7 @@ import { match } from '../../utils/match' import { RenderFeatures, forwardRefWithAs, - render, + useRender, type HasDisplayName, type PropsForFeatures, type RefProp, @@ -286,6 +286,8 @@ let InternalDialog = forwardRefWithAs(function InternalDialog< } } + let render = useRender() + return ( @@ -450,6 +452,8 @@ function PanelFn( let Wrapper = transition ? TransitionChild : Fragment let wrapperProps = transition ? { unmount } : {} + let render = useRender() + return ( {render({ @@ -494,6 +498,8 @@ function BackdropFn( let Wrapper = transition ? TransitionChild : Fragment let wrapperProps = transition ? { unmount } : {} + let render = useRender() + return ( {render({ @@ -541,6 +547,8 @@ function TitleFn( let ourProps = { ref: titleRef, id } + let render = useRender() + return render({ ourProps, theirProps, diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 1ae9684649..03a6e92e09 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -41,8 +41,7 @@ import { RenderFeatures, forwardRefWithAs, mergeProps, - render, - useMergeRefsFn, + useRender, type HasDisplayName, type PropsForFeatures, type RefProp, @@ -233,6 +232,8 @@ function DisclosureFn( ref: disclosureRef, } + let render = useRender() + return ( @@ -304,7 +305,6 @@ function ButtonFn( return dispatch({ type: ActionTypes.SetButtonElement, element }) }) ) - let mergeRefs = useMergeRefsFn() useEffect(() => { if (isWithinPanel) return @@ -411,8 +411,9 @@ function ButtonFn( pressProps ) + let render = useRender() + return render({ - mergeRefs, ourProps, theirProps, slot, @@ -451,7 +452,6 @@ function PanelFn( } = props let [state, dispatch] = useDisclosureContext('Disclosure.Panel') let { close } = useDisclosureAPIContext('Disclosure.Panel') - let mergeRefs = useMergeRefsFn() // To improve the correctness of transitions (timing related race conditions), // we track the element locally to this component, instead of relying on the @@ -496,11 +496,12 @@ function PanelFn( ...transitionDataAttributes(transitionData), } + let render = useRender() + return ( {render({ - mergeRefs, ourProps, theirProps, slot, diff --git a/packages/@headlessui-react/src/components/field/field.tsx b/packages/@headlessui-react/src/components/field/field.tsx index 27d9ae154d..c521341fb3 100644 --- a/packages/@headlessui-react/src/components/field/field.tsx +++ b/packages/@headlessui-react/src/components/field/field.tsx @@ -6,7 +6,7 @@ import { DisabledProvider, useDisabled } from '../../internal/disabled' import { FormFieldsProvider } from '../../internal/form-fields' import { IdProvider } from '../../internal/id' import type { Props } from '../../types' -import { forwardRefWithAs, render, type HasDisplayName } from '../../utils/render' +import { forwardRefWithAs, useRender, type HasDisplayName } from '../../utils/render' import { useDescriptions } from '../description/description' import { useLabels } from '../label/label' @@ -44,6 +44,8 @@ function FieldFn( 'aria-disabled': disabled || undefined, } + let render = useRender() + return ( diff --git a/packages/@headlessui-react/src/components/fieldset/fieldset.tsx b/packages/@headlessui-react/src/components/fieldset/fieldset.tsx index ae8a1eab20..c30361cdb8 100644 --- a/packages/@headlessui-react/src/components/fieldset/fieldset.tsx +++ b/packages/@headlessui-react/src/components/fieldset/fieldset.tsx @@ -5,7 +5,7 @@ import { useResolvedTag } from '../../hooks/use-resolved-tag' import { useSyncRefs } from '../../hooks/use-sync-refs' import { DisabledProvider, useDisabled } from '../../internal/disabled' import type { Props } from '../../types' -import { forwardRefWithAs, render, type HasDisplayName } from '../../utils/render' +import { forwardRefWithAs, useRender, type HasDisplayName } from '../../utils/render' import { useLabels } from '../label/label' let DEFAULT_FIELDSET_TAG = 'fieldset' as const @@ -50,6 +50,8 @@ function FieldsetFn( 'aria-disabled': disabled || undefined, } + let render = useRender() + return ( diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx index 6ee1a539d4..482f2dd33f 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx @@ -24,7 +24,7 @@ import { history } from '../../utils/active-element-history' import { Focus, FocusResult, focusElement, focusIn } from '../../utils/focus-management' import { match } from '../../utils/match' import { microTask } from '../../utils/micro-task' -import { forwardRefWithAs, render, type HasDisplayName, type RefProp } from '../../utils/render' +import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render' type Containers = // Lazy resolved containers @@ -197,6 +197,8 @@ function FocusTrapFn( }, } + let render = useRender() + return ( <> {tabLockEnabled && ( diff --git a/packages/@headlessui-react/src/components/input/input.tsx b/packages/@headlessui-react/src/components/input/input.tsx index 212465ee33..e4665dc22e 100644 --- a/packages/@headlessui-react/src/components/input/input.tsx +++ b/packages/@headlessui-react/src/components/input/input.tsx @@ -10,7 +10,7 @@ import type { Props } from '../../types' import { forwardRefWithAs, mergeProps, - render, + useRender, type HasDisplayName, type RefProp, } from '../../utils/render' @@ -78,6 +78,8 @@ function InputFn( return { disabled, invalid, hover, focus, autofocus: autoFocus } satisfies InputRenderPropArg }, [disabled, invalid, hover, focus, autoFocus]) + let render = useRender() + return render({ ourProps, theirProps, diff --git a/packages/@headlessui-react/src/components/label/label.tsx b/packages/@headlessui-react/src/components/label/label.tsx index 73c7c96b39..edde21637a 100644 --- a/packages/@headlessui-react/src/components/label/label.tsx +++ b/packages/@headlessui-react/src/components/label/label.tsx @@ -17,7 +17,7 @@ import { useSyncRefs } from '../../hooks/use-sync-refs' import { useDisabled } from '../../internal/disabled' import { useProvidedId } from '../../internal/id' import type { Props } from '../../types' -import { forwardRefWithAs, render, type HasDisplayName, type RefProp } from '../../utils/render' +import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render' // --- @@ -203,6 +203,8 @@ function LabelFn( } } + let render = useRender() + return render({ ourProps, theirProps, diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 7fc4e4a01d..fdde28dd46 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -74,8 +74,7 @@ import { RenderFeatures, forwardRefWithAs, mergeProps, - render, - useMergeRefsFn, + useRender, type HasDisplayName, type PropsForFeatures, type RefProp, @@ -698,6 +697,8 @@ function ListboxFn< return theirOnChange?.(defaultValue) }, [theirOnChange, defaultValue]) + let render = useRender() + return ( ( autoFocus = false, ...theirProps } = props - let mergeRefs = useMergeRefsFn() let buttonRef = useSyncRefs(ref, useFloatingReference(), actions.setButtonElement) let getFloatingReferenceProps = useFloatingReferenceProps() @@ -881,8 +881,9 @@ function ButtonFn( pressProps ) + let render = useRender() + return render({ - mergeRefs, ourProps, theirProps, slot, @@ -1159,6 +1160,8 @@ function OptionsFn( ...transitionDataAttributes(transitionData), }) + let render = useRender() + return ( {render({ diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 251cc021ae..f93afd2ed2 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -67,8 +67,7 @@ import { RenderFeatures, forwardRefWithAs, mergeProps, - render, - useMergeRefsFn, + useRender, type HasDisplayName, type RefProp, } from '../../utils/render' @@ -426,6 +425,8 @@ function MenuFn( let ourProps = { ref: menuRef } + let render = useRender() + return ( @@ -484,7 +485,6 @@ function ButtonFn( } = props let [state, dispatch] = useMenuContext('Menu.Button') let getFloatingReferenceProps = useFloatingReferenceProps() - let mergeRefs = useMergeRefsFn() let buttonRef = useSyncRefs( ref, useFloatingReference(), @@ -571,8 +571,9 @@ function ButtonFn( pressProps ) + let render = useRender() + return render({ - mergeRefs, ourProps, theirProps, slot, @@ -820,6 +821,8 @@ function ItemsFn( ...transitionDataAttributes(transitionData), }) + let render = useRender() + return ( {render({ @@ -982,6 +985,8 @@ function ItemFn( onMouseLeave: handleLeave, } + let render = useRender() + return ( @@ -1018,6 +1023,8 @@ function SectionFn( let theirProps = props let ourProps = { ref, 'aria-labelledby': labelledby, role: 'group' } + let render = useRender() + return ( {render({ @@ -1055,6 +1062,8 @@ function HeadingFn( let ourProps = { id, ref, role: 'presentation', ...context.props } + let render = useRender() + return render({ ourProps, theirProps, @@ -1083,6 +1092,8 @@ function SeparatorFn( let theirProps = props let ourProps = { ref, role: 'separator' } + let render = useRender() + return render({ ourProps, theirProps, diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 386ec35794..3d772c2e0d 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -74,8 +74,7 @@ import { RenderFeatures, forwardRefWithAs, mergeProps, - render, - useMergeRefsFn, + useRender, type HasDisplayName, type PropsForFeatures, type RefProp, @@ -419,6 +418,8 @@ function PopoverFn( let ourProps = { ref: popoverRef } + let render = useRender() + return ( @@ -707,6 +708,8 @@ function ButtonFn( } }) + let render = useRender() + return ( <> {render({ @@ -799,6 +802,8 @@ function BackdropFn( ...transitionDataAttributes(transitionData), } + let render = useRender() + return render({ ourProps, theirProps, @@ -884,7 +889,6 @@ function PanelFn( setLocalPanelElement ) let ownerDocument = useOwnerDocument(internalPanelRef) - let mergeRefs = useMergeRefsFn() useIsoMorphicEffect(() => { dispatch({ type: ActionTypes.SetPanelId, panelId: id }) @@ -1070,6 +1074,8 @@ function PanelFn( } }) + let render = useRender() + return ( @@ -1087,7 +1093,6 @@ function PanelFn( /> )} {render({ - mergeRefs, ourProps, theirProps, slot, @@ -1188,6 +1193,8 @@ function GroupFn( let theirProps = props let ourProps = { ref: groupRef } + let render = useRender() + return ( diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index efc0cba2f8..4c05ce0700 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -23,7 +23,7 @@ import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs' import { usePortalRoot } from '../../internal/portal-force-root' import type { Props } from '../../types' import { env } from '../../utils/env' -import { forwardRefWithAs, render, type HasDisplayName, type RefProp } from '../../utils/render' +import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render' function usePortalTarget(ref: MutableRefObject): HTMLElement | null { let forceInRoot = usePortalRoot() @@ -129,6 +129,7 @@ let InternalPortalFn = forwardRefWithAs(function InternalPortalFn< } }) + let render = useRender() if (!ready) return null let ourProps = { ref: portalRef } @@ -154,6 +155,9 @@ function PortalFn( let portalRef = useSyncRefs(ref) let { enabled = true, ...theirProps } = props + + let render = useRender() + return enabled ? ( ) : ( @@ -193,6 +197,8 @@ function GroupFn( let ourProps = { ref: groupRef } + let render = useRender() + return ( {render({ 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 bfeaee8033..a43d334254 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -35,7 +35,7 @@ import { getOwnerDocument } from '../../utils/owner' import { forwardRefWithAs, mergeProps, - render, + useRender, type HasDisplayName, type RefProp, } from '../../utils/render' @@ -309,6 +309,8 @@ function RadioGroupFn @@ -444,6 +446,8 @@ function OptionFn< } satisfies OptionRenderPropArg }, [checked, disabled, hover, focus, autoFocus]) + let render = useRender() + return ( @@ -557,6 +561,8 @@ function RadioFn< return { checked, disabled, hover, focus, autofocus: autoFocus } satisfies RadioRenderPropArg }, [checked, disabled, hover, focus, autoFocus]) + let render = useRender() + return render({ ourProps, theirProps, diff --git a/packages/@headlessui-react/src/components/select/select.tsx b/packages/@headlessui-react/src/components/select/select.tsx index afcc900fa2..82d0653a32 100644 --- a/packages/@headlessui-react/src/components/select/select.tsx +++ b/packages/@headlessui-react/src/components/select/select.tsx @@ -11,7 +11,7 @@ import type { Props } from '../../types' import { forwardRefWithAs, mergeProps, - render, + useRender, type HasDisplayName, type RefProp, } from '../../utils/render' @@ -89,6 +89,8 @@ function SelectFn( } satisfies SelectRenderPropArg }, [disabled, invalid, hover, focus, active, autoFocus]) + let render = useRender() + return render({ ourProps, theirProps, diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx index 989fe817f3..f3c9df671b 100644 --- a/packages/@headlessui-react/src/components/switch/switch.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.tsx @@ -32,7 +32,7 @@ import { attemptSubmit } from '../../utils/form' import { forwardRefWithAs, mergeProps, - render, + useRender, type HasDisplayName, type RefProp, } from '../../utils/render' @@ -74,6 +74,8 @@ function GroupFn( let ourProps = {} let theirProps = props + let render = useRender() + return ( ( return onChange?.(defaultChecked) }, [onChange, defaultChecked]) + let render = useRender() + return ( <> {name != null && ( diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index 52c925212c..651c2630df 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -33,7 +33,7 @@ import { RenderFeatures, forwardRefWithAs, mergeProps, - render, + useRender, type HasDisplayName, type PropsForFeatures, type RefProp, @@ -320,6 +320,8 @@ function GroupFn( let ourProps = { ref: tabsRef } + let render = useRender() + return ( @@ -384,6 +386,8 @@ function ListFn( 'aria-orientation': orientation, } + let render = useRender() + return render({ ourProps, theirProps, @@ -556,6 +560,8 @@ function TabFn( pressProps ) + let render = useRender() + return render({ ourProps, theirProps, @@ -589,6 +595,8 @@ function PanelsFn( let theirProps = props let ourProps = { ref: panelsRef } + let render = useRender() + return render({ ourProps, theirProps, @@ -650,6 +658,8 @@ function PanelFn( focusProps ) + let render = useRender() + if (!selected && (theirProps.unmount ?? true) && !(theirProps.static ?? false)) { return