Skip to content

Commit b670896

Browse files
authored
Fix crash when using as={Fragment} on MenuButton, ListboxButton, DisclosureButton or Button components (#3478)
This PR fixes an issue where a maximum update depth exceeded error was thrown when using `as={Fragment}` on button related components. The issue here is that the `ref` on a element would re-fire every render _if_ the a function was used _and_ the function is a new function (aka not a stable function). This resulted in the `ref` being called with the DOM element, then `null`, then the DOM element, then `null`, and so on. To solve this, we have to make sure that the `ref` is always a stable reference. Fixes: #3476 Fixes: #3439
1 parent dde00da commit b670896

File tree

10 files changed

+128
-5
lines changed

10 files changed

+128
-5
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Fixed
11+
12+
- Fix crash when using `as={Fragment}` on `MenuButton`, `ListboxButton`, `DisclosureButton` or `Button` components ([#3478](https://github.com/tailwindlabs/headlessui/pull/3478))
1113

1214
## [2.1.7] - 2024-09-11
1315

packages/@headlessui-react/src/components/button/button.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { render, screen } from '@testing-library/react'
2-
import React from 'react'
2+
import React, { Fragment } from 'react'
33
import { Button } from './button'
44

55
describe('Rendering', () => {
@@ -35,5 +35,15 @@ describe('Rendering', () => {
3535

3636
expect(screen.getByRole('button')).toHaveAttribute('data-autofocus')
3737
})
38+
39+
it('should be possible to render a Button using as={Fragment}', async () => {
40+
render(
41+
<Button as={Fragment}>
42+
<button>Toggle</button>
43+
</Button>
44+
)
45+
46+
expect(screen.getByRole('button')).toHaveAttribute('type')
47+
})
3848
})
3949
})

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
forwardRefWithAs,
1111
mergeProps,
1212
render,
13+
useMergeRefsFn,
1314
type HasDisplayName,
1415
type RefProp,
1516
} from '../../utils/render'
@@ -41,6 +42,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
4142
ref: Ref<HTMLElement>
4243
) {
4344
let providedDisabled = useDisabled()
45+
let mergeRefs = useMergeRefsFn()
4446
let { disabled = providedDisabled || false, autoFocus = false, ...theirProps } = props
4547

4648
let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus })
@@ -64,6 +66,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
6466
}, [disabled, hover, focus, active, autoFocus])
6567

6668
return render({
69+
mergeRefs,
6770
ourProps,
6871
theirProps,
6972
slot,

packages/@headlessui-react/src/components/combobox/combobox.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,6 +1211,31 @@ describe('Rendering', () => {
12111211
expect(getComboboxButton()).not.toHaveAttribute('type')
12121212
})
12131213
})
1214+
1215+
it(
1216+
'should be possible to render a ComboboxButton using as={Fragment}',
1217+
suppressConsoleLogs(async () => {
1218+
render(
1219+
<Combobox>
1220+
<ComboboxInput />
1221+
<ComboboxButton as={Fragment}>
1222+
<button>Toggle</button>
1223+
</ComboboxButton>
1224+
<ComboboxOptions>
1225+
<ComboboxOption value="a">Option A</ComboboxOption>
1226+
<ComboboxOption value="b">Option B</ComboboxOption>
1227+
<ComboboxOption value="c">Option C</ComboboxOption>
1228+
</ComboboxOptions>
1229+
</Combobox>
1230+
)
1231+
1232+
assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })
1233+
1234+
await click(getComboboxButton())
1235+
1236+
assertComboboxButton({ state: ComboboxState.Visible })
1237+
})
1238+
)
12141239
})
12151240

12161241
describe('Combobox.Options', () => {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
forwardRefWithAs,
7171
mergeProps,
7272
render,
73+
useMergeRefsFn,
7374
type HasDisplayName,
7475
type PropsForFeatures,
7576
type RefProp,
@@ -1495,6 +1496,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
14951496
let data = useData('Combobox.Button')
14961497
let actions = useActions('Combobox.Button')
14971498
let buttonRef = useSyncRefs(ref, actions.setButtonElement)
1499+
let mergeRefs = useMergeRefsFn()
14981500

14991501
let internalId = useId()
15001502
let {
@@ -1616,6 +1618,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
16161618
)
16171619

16181620
return render({
1621+
mergeRefs,
16191622
ourProps,
16201623
theirProps,
16211624
slot,

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { render, waitFor } from '@testing-library/react'
2-
import React, { Suspense, createElement, useEffect, useRef } from 'react'
2+
import React, { Fragment, Suspense, createElement, useEffect, useRef } from 'react'
33
import {
44
DisclosureState,
55
assertActiveElement,
@@ -439,6 +439,28 @@ describe('Rendering', () => {
439439
expect(getDisclosureButton()).not.toHaveAttribute('type')
440440
})
441441
})
442+
443+
it(
444+
'should be possible to render a DisclosureButton using as={Fragment}',
445+
suppressConsoleLogs(async () => {
446+
render(
447+
<Disclosure>
448+
<DisclosureButton as={Fragment}>
449+
<button>Toggle</button>
450+
</DisclosureButton>
451+
<DisclosurePanel>Contents</DisclosurePanel>
452+
</Disclosure>
453+
)
454+
455+
assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
456+
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
457+
458+
await click(getDisclosureButton())
459+
460+
assertDisclosureButton({ state: DisclosureState.Visible })
461+
assertDisclosurePanel({ state: DisclosureState.Visible })
462+
})
463+
)
442464
})
443465

444466
describe('Disclosure.Panel', () => {

packages/@headlessui-react/src/components/listbox/listbox.test.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { render, waitFor } from '@testing-library/react'
2-
import React, { createElement, useEffect, useState } from 'react'
2+
import React, { Fragment, createElement, useEffect, useState } from 'react'
33
import {
44
ListboxMode,
55
ListboxState,
@@ -760,6 +760,32 @@ describe('Rendering', () => {
760760
expect(getListboxButton()).not.toHaveAttribute('type')
761761
})
762762
})
763+
764+
it(
765+
'should be possible to render a ListboxButton using as={Fragment}',
766+
suppressConsoleLogs(async () => {
767+
render(
768+
<Listbox>
769+
<ListboxButton as={Fragment}>
770+
<button>Toggle</button>
771+
</ListboxButton>
772+
<ListboxOptions>
773+
<ListboxOption value="a">Option A</ListboxOption>
774+
<ListboxOption value="b">Option B</ListboxOption>
775+
<ListboxOption value="c">Option C</ListboxOption>
776+
</ListboxOptions>
777+
</Listbox>
778+
)
779+
780+
assertListboxButton({ state: ListboxState.InvisibleUnmounted })
781+
assertListbox({ state: ListboxState.InvisibleUnmounted })
782+
783+
await click(getListboxButton())
784+
785+
assertListboxButton({ state: ListboxState.Visible })
786+
assertListbox({ state: ListboxState.Visible })
787+
})
788+
)
763789
})
764790

765791
describe('Listbox.Options', () => {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
forwardRefWithAs,
7676
mergeProps,
7777
render,
78+
useMergeRefsFn,
7879
type HasDisplayName,
7980
type PropsForFeatures,
8081
type RefProp,
@@ -785,6 +786,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
785786
autoFocus = false,
786787
...theirProps
787788
} = props
789+
let mergeRefs = useMergeRefsFn()
788790
let buttonRef = useSyncRefs(ref, useFloatingReference(), actions.setButtonElement)
789791
let getFloatingReferenceProps = useFloatingReferenceProps()
790792

@@ -880,6 +882,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
880882
)
881883

882884
return render({
885+
mergeRefs,
883886
ourProps,
884887
theirProps,
885888
slot,

packages/@headlessui-react/src/components/menu/menu.test.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { render, waitFor } from '@testing-library/react'
2-
import React, { createElement, useEffect } from 'react'
2+
import React, { Fragment, createElement, useEffect } from 'react'
33
import {
44
MenuState,
55
assertActiveElement,
@@ -306,6 +306,32 @@ describe('Rendering', () => {
306306
expect(getMenuButton()).not.toHaveAttribute('type')
307307
})
308308
})
309+
310+
it(
311+
'should be possible to render a MenuButton using as={Fragment}',
312+
suppressConsoleLogs(async () => {
313+
render(
314+
<Menu>
315+
<MenuButton as={Fragment}>
316+
<button>Toggle</button>
317+
</MenuButton>
318+
<MenuItems>
319+
<MenuItem as="a">Item A</MenuItem>
320+
<MenuItem as="a">Item B</MenuItem>
321+
<MenuItem as="a">Item C</MenuItem>
322+
</MenuItems>
323+
</Menu>
324+
)
325+
326+
assertMenuButton({ state: MenuState.InvisibleUnmounted })
327+
assertMenu({ state: MenuState.InvisibleUnmounted })
328+
329+
await click(getMenuButton())
330+
331+
assertMenuButton({ state: MenuState.Visible })
332+
assertMenu({ state: MenuState.Visible })
333+
})
334+
)
309335
})
310336

311337
describe('Menu.Items', () => {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
forwardRefWithAs,
6969
mergeProps,
7070
render,
71+
useMergeRefsFn,
7172
type HasDisplayName,
7273
type RefProp,
7374
} from '../../utils/render'
@@ -483,6 +484,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
483484
} = props
484485
let [state, dispatch] = useMenuContext('Menu.Button')
485486
let getFloatingReferenceProps = useFloatingReferenceProps()
487+
let mergeRefs = useMergeRefsFn()
486488
let buttonRef = useSyncRefs(
487489
ref,
488490
useFloatingReference(),
@@ -570,6 +572,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
570572
)
571573

572574
return render({
575+
mergeRefs,
573576
ourProps,
574577
theirProps,
575578
slot,

0 commit comments

Comments
 (0)