Skip to content

Commit 0f34486

Browse files
Make sure as={Fragment} doesn’t result in a render loop (#2760)
* Make sure `as={Fragment}` doesn’t result in a render loop * Add comment about usage * Fix render loop on popover panel too * Update changelog
1 parent 2a64c13 commit 0f34486

File tree

4 files changed

+72
-17
lines changed

4 files changed

+72
-17
lines changed

packages/@headlessui-react/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Ensure blurring the `Combobox.Input` component closes the `Combobox` ([#2712](https://github.com/tailwindlabs/headlessui/pull/2712))
1717
- Allow changes to the `className` prop when the `<Transition />` component is currently not transitioning ([#2722](https://github.com/tailwindlabs/headlessui/pull/2722))
1818
- Export (internal-only) component interfaces for TypeScript compiler ([#2313](https://github.com/tailwindlabs/headlessui/pull/2313))
19+
- Fix infinite render-loop for `<Disclosure.Panel>` and `<Popover.Panel>` when `as={Fragment}` ([#2760](https://github.com/tailwindlabs/headlessui/pull/2760))
1920

2021
### Added
2122

packages/@headlessui-react/src/components/disclosure/disclosure.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
Features,
2929
forwardRefWithAs,
3030
render,
31+
useMergeRefsFn,
3132
type HasDisplayName,
3233
type PropsForFeatures,
3334
type RefProp,
@@ -267,6 +268,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
267268

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

271273
useEffect(() => {
272274
if (isWithinPanel) return
@@ -345,6 +347,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
345347
}
346348

347349
return render({
350+
mergeRefs,
348351
ourProps,
349352
theirProps,
350353
slot,
@@ -374,6 +377,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
374377
let { id = `headlessui-disclosure-panel-${internalId}`, ...theirProps } = props
375378
let [state, dispatch] = useDisclosureContext('Disclosure.Panel')
376379
let { close } = useDisclosureAPIContext('Disclosure.Panel')
380+
let mergeRefs = useMergeRefsFn()
377381

378382
let panelRef = useSyncRefs(ref, state.panelRef, (el) => {
379383
startTransition(() => dispatch({ type: el ? ActionTypes.LinkPanel : ActionTypes.UnlinkPanel }))
@@ -408,6 +412,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
408412
return (
409413
<DisclosurePanelContext.Provider value={state.panelId}>
410414
{render({
415+
mergeRefs,
411416
ourProps,
412417
theirProps,
413418
slot,

packages/@headlessui-react/src/components/popover/popover.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
Features,
5050
forwardRefWithAs,
5151
render,
52+
useMergeRefsFn,
5253
type HasDisplayName,
5354
type PropsForFeatures,
5455
type RefProp,
@@ -755,6 +756,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
755756
dispatch({ type: ActionTypes.SetPanel, panel })
756757
})
757758
let ownerDocument = useOwnerDocument(internalPanelRef)
759+
let mergeRefs = useMergeRefsFn()
758760

759761
useIsoMorphicEffect(() => {
760762
dispatch({ type: ActionTypes.SetPanelId, panelId: id })
@@ -934,6 +936,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
934936
/>
935937
)}
936938
{render({
939+
mergeRefs,
937940
ourProps,
938941
theirProps,
939942
slot,

packages/@headlessui-react/src/utils/render.ts

+63-17
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import {
44
forwardRef,
55
Fragment,
66
isValidElement,
7+
MutableRefObject,
8+
useCallback,
9+
useRef,
710
type ElementType,
811
type ReactElement,
912
type Ref,
@@ -54,6 +57,7 @@ export function render<TFeature extends Features, TTag extends ElementType, TSlo
5457
features,
5558
visible = true,
5659
name,
60+
mergeRefs,
5761
}: {
5862
ourProps: Expand<Props<TTag, TSlot, any> & PropsForFeatures<TFeature>> & {
5963
ref?: Ref<HTMLElement | ElementType>
@@ -64,19 +68,22 @@ export function render<TFeature extends Features, TTag extends ElementType, TSlo
6468
features?: TFeature
6569
visible?: boolean
6670
name: string
71+
mergeRefs?: ReturnType<typeof useMergeRefsFn>
6772
}) {
73+
mergeRefs = mergeRefs ?? defaultMergeRefs
74+
6875
let props = mergeProps(theirProps, ourProps)
6976

7077
// Visible always render
71-
if (visible) return _render(props, slot, defaultTag, name)
78+
if (visible) return _render(props, slot, defaultTag, name, mergeRefs)
7279

7380
let featureFlags = features ?? Features.None
7481

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

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

8289
if (featureFlags & Features.RenderStrategy) {
@@ -92,21 +99,23 @@ export function render<TFeature extends Features, TTag extends ElementType, TSlo
9299
{ ...rest, ...{ hidden: true, style: { display: 'none' } } },
93100
slot,
94101
defaultTag,
95-
name
102+
name,
103+
mergeRefs!
96104
)
97105
},
98106
})
99107
}
100108

101109
// No features enabled, just render
102-
return _render(props, slot, defaultTag, name)
110+
return _render(props, slot, defaultTag, name, mergeRefs)
103111
}
104112

105113
function _render<TTag extends ElementType, TSlot>(
106114
props: Props<TTag, TSlot> & { ref?: unknown },
107115
slot: TSlot = {} as TSlot,
108116
tag: ElementType,
109-
name: string
117+
name: string,
118+
mergeRefs: ReturnType<typeof useMergeRefsFn>
110119
) {
111120
let {
112121
as: Component = tag,
@@ -189,7 +198,7 @@ function _render<TTag extends ElementType, TSlot>(
189198
mergeProps(resolvedChildren.props as any, compact(omit(rest, ['ref']))),
190199
dataAttributes,
191200
refRelatedProps,
192-
mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref),
201+
{ ref: mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref) },
193202
classNameProps
194203
)
195204
)
@@ -208,20 +217,57 @@ function _render<TTag extends ElementType, TSlot>(
208217
)
209218
}
210219

211-
function mergeRefs(...refs: any[]) {
212-
return {
213-
ref: refs.every((ref) => ref == null)
214-
? undefined
215-
: (value: any) => {
216-
for (let ref of refs) {
217-
if (ref == null) continue
218-
if (typeof ref === 'function') ref(value)
219-
else ref.current = value
220-
}
221-
},
220+
/**
221+
* This is a singleton hook. **You can ONLY call the returned
222+
* function *once* to produce expected results.** If you need
223+
* to call `mergeRefs()` multiple times you need to create a
224+
* separate function for each invocation. This happens as we
225+
* store the list of `refs` to update and always return the
226+
* same function that refers to that list of refs.
227+
*
228+
* You shouldn't normally read refs during render but this
229+
* should actually be okay because React itself is calling
230+
* the `function` that updates these refs and can only do
231+
* so once the ref that contains the list is updated.
232+
*/
233+
export function useMergeRefsFn() {
234+
type MaybeRef<T> = MutableRefObject<T> | ((value: T) => void) | null | undefined
235+
let currentRefs = useRef<MaybeRef<any>[]>([])
236+
let mergedRef = useCallback((value) => {
237+
for (let ref of currentRefs.current) {
238+
if (ref == null) continue
239+
if (typeof ref === 'function') ref(value)
240+
else ref.current = value
241+
}
242+
}, [])
243+
244+
return (...refs: any[]) => {
245+
if (refs.every((ref) => ref == null)) {
246+
return undefined
247+
}
248+
249+
currentRefs.current = refs
250+
return mergedRef
222251
}
223252
}
224253

254+
// This does not produce a stable function to use as a ref
255+
// But we only use it in the case of as={Fragment}
256+
// And it should really only re-render if setting the ref causes the parent to re-render unconditionally
257+
// which then causes the child to re-render resulting in a render loop
258+
// TODO: Add tests for this somehow
259+
function defaultMergeRefs(...refs: any[]) {
260+
return refs.every((ref) => ref == null)
261+
? undefined
262+
: (value: any) => {
263+
for (let ref of refs) {
264+
if (ref == null) continue
265+
if (typeof ref === 'function') ref(value)
266+
else ref.current = value
267+
}
268+
}
269+
}
270+
225271
function mergeProps(...listOfProps: Props<any, any>[]) {
226272
if (listOfProps.length === 0) return {}
227273
if (listOfProps.length === 1) return listOfProps[0]

0 commit comments

Comments
 (0)