Skip to content

Commit

Permalink
Make sure as={Fragment} doesn’t result in a render loop (#2760)
Browse files Browse the repository at this point in the history
* Make sure `as={Fragment}` doesn’t result in a render loop

* Add comment about usage

* Fix render loop on popover panel too

* Update changelog
  • Loading branch information
thecrypticace committed Sep 20, 2023
1 parent 2a64c13 commit 0f34486
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 17 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure blurring the `Combobox.Input` component closes the `Combobox` ([#2712](https://github.com/tailwindlabs/headlessui/pull/2712))
- Allow changes to the `className` prop when the `<Transition />` component is currently not transitioning ([#2722](https://github.com/tailwindlabs/headlessui/pull/2722))
- Export (internal-only) component interfaces for TypeScript compiler ([#2313](https://github.com/tailwindlabs/headlessui/pull/2313))
- Fix infinite render-loop for `<Disclosure.Panel>` and `<Popover.Panel>` when `as={Fragment}` ([#2760](https://github.com/tailwindlabs/headlessui/pull/2760))

### Added

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
Features,
forwardRefWithAs,
render,
useMergeRefsFn,
type HasDisplayName,
type PropsForFeatures,
type RefProp,
Expand Down Expand Up @@ -267,6 +268,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(

let internalButtonRef = useRef<HTMLButtonElement | null>(null)
let buttonRef = useSyncRefs(internalButtonRef, ref, !isWithinPanel ? state.buttonRef : null)
let mergeRefs = useMergeRefsFn()

useEffect(() => {
if (isWithinPanel) return
Expand Down Expand Up @@ -345,6 +347,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}

return render({
mergeRefs,
ourProps,
theirProps,
slot,
Expand Down Expand Up @@ -374,6 +377,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
let { id = `headlessui-disclosure-panel-${internalId}`, ...theirProps } = props
let [state, dispatch] = useDisclosureContext('Disclosure.Panel')
let { close } = useDisclosureAPIContext('Disclosure.Panel')
let mergeRefs = useMergeRefsFn()

let panelRef = useSyncRefs(ref, state.panelRef, (el) => {
startTransition(() => dispatch({ type: el ? ActionTypes.LinkPanel : ActionTypes.UnlinkPanel }))
Expand Down Expand Up @@ -408,6 +412,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
return (
<DisclosurePanelContext.Provider value={state.panelId}>
{render({
mergeRefs,
ourProps,
theirProps,
slot,
Expand Down
3 changes: 3 additions & 0 deletions packages/@headlessui-react/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
Features,
forwardRefWithAs,
render,
useMergeRefsFn,
type HasDisplayName,
type PropsForFeatures,
type RefProp,
Expand Down Expand Up @@ -755,6 +756,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
dispatch({ type: ActionTypes.SetPanel, panel })
})
let ownerDocument = useOwnerDocument(internalPanelRef)
let mergeRefs = useMergeRefsFn()

useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.SetPanelId, panelId: id })
Expand Down Expand Up @@ -934,6 +936,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
/>
)}
{render({
mergeRefs,
ourProps,
theirProps,
slot,
Expand Down
80 changes: 63 additions & 17 deletions packages/@headlessui-react/src/utils/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {
forwardRef,
Fragment,
isValidElement,
MutableRefObject,
useCallback,
useRef,
type ElementType,
type ReactElement,
type Ref,
Expand Down Expand Up @@ -54,6 +57,7 @@ export function render<TFeature extends Features, TTag extends ElementType, TSlo
features,
visible = true,
name,
mergeRefs,
}: {
ourProps: Expand<Props<TTag, TSlot, any> & PropsForFeatures<TFeature>> & {
ref?: Ref<HTMLElement | ElementType>
Expand All @@ -64,19 +68,22 @@ export function render<TFeature extends Features, TTag extends ElementType, TSlo
features?: TFeature
visible?: boolean
name: string
mergeRefs?: ReturnType<typeof useMergeRefsFn>
}) {
mergeRefs = mergeRefs ?? defaultMergeRefs

let props = mergeProps(theirProps, ourProps)

// Visible always render
if (visible) return _render(props, slot, defaultTag, name)
if (visible) return _render(props, slot, defaultTag, name, mergeRefs)

let featureFlags = features ?? Features.None

if (featureFlags & Features.Static) {
let { static: isStatic = false, ...rest } = props as PropsForFeatures<Features.Static>

// When the `static` prop is passed as `true`, then the user is in control, thus we don't care about anything else
if (isStatic) return _render(rest, slot, defaultTag, name)
if (isStatic) return _render(rest, slot, defaultTag, name, mergeRefs)
}

if (featureFlags & Features.RenderStrategy) {
Expand All @@ -92,21 +99,23 @@ export function render<TFeature extends Features, TTag extends ElementType, TSlo
{ ...rest, ...{ hidden: true, style: { display: 'none' } } },
slot,
defaultTag,
name
name,
mergeRefs!
)
},
})
}

// No features enabled, just render
return _render(props, slot, defaultTag, name)
return _render(props, slot, defaultTag, name, mergeRefs)
}

function _render<TTag extends ElementType, TSlot>(
props: Props<TTag, TSlot> & { ref?: unknown },
slot: TSlot = {} as TSlot,
tag: ElementType,
name: string
name: string,
mergeRefs: ReturnType<typeof useMergeRefsFn>
) {
let {
as: Component = tag,
Expand Down Expand Up @@ -189,7 +198,7 @@ function _render<TTag extends ElementType, TSlot>(
mergeProps(resolvedChildren.props as any, compact(omit(rest, ['ref']))),
dataAttributes,
refRelatedProps,
mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref),
{ ref: mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref) },
classNameProps
)
)
Expand All @@ -208,20 +217,57 @@ function _render<TTag extends ElementType, TSlot>(
)
}

function mergeRefs(...refs: any[]) {
return {
ref: refs.every((ref) => ref == null)
? undefined
: (value: any) => {
for (let ref of refs) {
if (ref == null) continue
if (typeof ref === 'function') ref(value)
else ref.current = value
}
},
/**
* This is a singleton hook. **You can ONLY call the returned
* function *once* to produce expected results.** If you need
* to call `mergeRefs()` multiple times you need to create a
* separate function for each invocation. This happens as we
* store the list of `refs` to update and always return the
* same function that refers to that list of refs.
*
* You shouldn't normally read refs during render but this
* should actually be okay because React itself is calling
* the `function` that updates these refs and can only do
* so once the ref that contains the list is updated.
*/
export function useMergeRefsFn() {
type MaybeRef<T> = MutableRefObject<T> | ((value: T) => void) | null | undefined
let currentRefs = useRef<MaybeRef<any>[]>([])
let mergedRef = useCallback((value) => {
for (let ref of currentRefs.current) {
if (ref == null) continue
if (typeof ref === 'function') ref(value)
else ref.current = value
}
}, [])

return (...refs: any[]) => {
if (refs.every((ref) => ref == null)) {
return undefined
}

currentRefs.current = refs
return mergedRef
}
}

// This does not produce a stable function to use as a ref
// But we only use it in the case of as={Fragment}
// And it should really only re-render if setting the ref causes the parent to re-render unconditionally
// which then causes the child to re-render resulting in a render loop
// TODO: Add tests for this somehow
function defaultMergeRefs(...refs: any[]) {
return refs.every((ref) => ref == null)
? undefined
: (value: any) => {
for (let ref of refs) {
if (ref == null) continue
if (typeof ref === 'function') ref(value)
else ref.current = value
}
}
}

function mergeProps(...listOfProps: Props<any, any>[]) {
if (listOfProps.length === 0) return {}
if (listOfProps.length === 1) return listOfProps[0]
Expand Down

2 comments on commit 0f34486

@vercel
Copy link

@vercel vercel bot commented on 0f34486 Sep 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

headlessui-vue – ./packages/playground-vue

headlessui-vue-tailwindlabs.vercel.app
headlessui-vue.vercel.app
headlessui-vue-git-main-tailwindlabs.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 0f34486 Sep 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

headlessui-react – ./packages/playground-react

headlessui-react.vercel.app
headlessui-react-tailwindlabs.vercel.app
headlessui-react-git-main-tailwindlabs.vercel.app

Please sign in to comment.