Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make sure as={Fragment} doesn’t result in a render loop #2760

Merged
merged 4 commits into from
Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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