Repository: tailwindlabs/headlessui
Pull request #3478 (2024-09-12 09:14:46)

Fix crash when using `as={Fragment}` on `MenuButton`, `ListboxButton`, `DisclosureButton` or `Button` components
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


vercel[bot]: [vc]: #2IzL9FGxp3FKsZIlqyi+FH4k8AprHd0UhsnDV4697VM=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXJlYWN0Iiwicm9vdERpcmVjdG9yeSI6InBsYXlncm91bmRzL3JlYWN0IiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RhaWx3aW5kbGFicy9oZWFkbGVzc3VpLXJlYWN0L0ZWbWpYaEJYOU5CazlTNUZRbkZUNERhVW44Y3EiLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS1yZWFjdC1naXQtZml4LWlzc3VlLTM0NzYtdGFpbHdpbmRsYWJzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiUEVORElORyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJoZWFkbGVzc3VpLXJlYWN0LWdpdC1maXgtaXNzdWUtMzQ3Ni10YWlsd2luZGxhYnMudmVyY2VsLmFwcCJ9fSx7Im5hbWUiOiJoZWFkbGVzc3VpLXZ1ZSIsInJvb3REaXJlY3RvcnkiOiJwbGF5Z3JvdW5kcy92dWUiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGFpbHdpbmRsYWJzL2hlYWRsZXNzdWktdnVlLzJQZEF4aVE0Mm82aUJmU2YxQlRYcFpKZGlEM1oiLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS12dWUtZ2l0LWZpeC1pc3N1ZS0zNDc2LXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkciLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiaGVhZGxlc3N1aS12dWUtZ2l0LWZpeC1pc3N1ZS0zNDc2LXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIn19XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **headlessui-react** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-react/FVmjXhBX9NBk9S5FQnFT4DaUn8cq)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-react-git-fix-issue-3476-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-react-git-fix-issue-3476-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Sep 12, 2024 9:14am |
| **headlessui-vue** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-vue/2PdAxiQ42o6iBfSf1BTXpZJdiD3Z)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-vue-git-fix-issue-3476-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-vue-git-fix-issue-3476-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Sep 12, 2024 9:14am |




Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 05e75b8..dc7c822 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
-- Nothing yet!
+### Fixed
+
+- Fix crash when using `as={Fragment}` on `MenuButton`, `ListboxButton`, `DisclosureButton` or `Button` components ([#3478](https://github.com/tailwindlabs/headlessui/pull/3478))
 
 ## [2.1.7] - 2024-09-11
 
diff --git a/packages/@headlessui-react/src/components/button/button.tsx b/packages/@headlessui-react/src/components/button/button.tsx
index ba5cdb9..96310a8 100644
--- a/packages/@headlessui-react/src/components/button/button.tsx
+++ b/packages/@headlessui-react/src/components/button/button.tsx
@@ -10,6 +10,7 @@ import {
   forwardRefWithAs,
   mergeProps,
   render,
+  useMergeRefsFn,
   type HasDisplayName,
   type RefProp,
 } from '../../utils/render'
@@ -41,6 +42,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
   ref: Ref<HTMLElement>
 ) {
   let providedDisabled = useDisabled()
+  let mergeRefs = useMergeRefsFn()
   let { disabled = providedDisabled || false, autoFocus = false, ...theirProps } = props
 
   let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus })
@@ -64,6 +66,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
   }, [disabled, hover, focus, active, autoFocus])
 
   return render({
+    mergeRefs,
     ourProps,
     theirProps,
     slot,
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index 8e582a0..6161293 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -70,6 +70,7 @@ import {
   forwardRefWithAs,
   mergeProps,
   render,
+  useMergeRefsFn,
   type HasDisplayName,
   type PropsForFeatures,
   type RefProp,
@@ -1495,6 +1496,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
   let data = useData('Combobox.Button')
   let actions = useActions('Combobox.Button')
   let buttonRef = useSyncRefs(ref, actions.setButtonElement)
+  let mergeRefs = useMergeRefsFn()
 
   let internalId = useId()
   let {
@@ -1616,6 +1618,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
   )
 
   return render({
+    mergeRefs,
     ourProps,
     theirProps,
     slot,
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index b4b9a72..e9a8150 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -75,6 +75,7 @@ import {
   forwardRefWithAs,
   mergeProps,
   render,
+  useMergeRefsFn,
   type HasDisplayName,
   type PropsForFeatures,
   type RefProp,
@@ -785,6 +786,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
     autoFocus = false,
     ...theirProps
   } = props
+  let mergeRefs = useMergeRefsFn()
   let buttonRef = useSyncRefs(ref, useFloatingReference(), actions.setButtonElement)
   let getFloatingReferenceProps = useFloatingReferenceProps()
 
@@ -880,6 +882,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
   )
 
   return render({
+    mergeRefs,
     ourProps,
     theirProps,
     slot,
diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx
index 88b0074..57a823e 100644
--- a/packages/@headlessui-react/src/components/menu/menu.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.tsx
@@ -68,6 +68,7 @@ import {
   forwardRefWithAs,
   mergeProps,
   render,
+  useMergeRefsFn,
   type HasDisplayName,
   type RefProp,
 } from '../../utils/render'
@@ -483,6 +484,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
   } = props
   let [state, dispatch] = useMenuContext('Menu.Button')
   let getFloatingReferenceProps = useFloatingReferenceProps()
+  let mergeRefs = useMergeRefsFn()
   let buttonRef = useSyncRefs(
     ref,
     useFloatingReference(),
@@ -570,6 +572,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
   )
 
   return render({
+    mergeRefs,
     ourProps,
     theirProps,
     slot,


Test patch:
diff --git a/packages/@headlessui-react/src/components/button/button.test.tsx b/packages/@headlessui-react/src/components/button/button.test.tsx
index c7d6c96..c421fb1 100644
--- a/packages/@headlessui-react/src/components/button/button.test.tsx
+++ b/packages/@headlessui-react/src/components/button/button.test.tsx
@@ -1,5 +1,5 @@
 import { render, screen } from '@testing-library/react'
-import React from 'react'
+import React, { Fragment } from 'react'
 import { Button } from './button'
 
 describe('Rendering', () => {
@@ -35,5 +35,15 @@ describe('Rendering', () => {
 
       expect(screen.getByRole('button')).toHaveAttribute('data-autofocus')
     })
+
+    it('should be possible to render a Button using as={Fragment}', async () => {
+      render(
+        <Button as={Fragment}>
+          <button>Toggle</button>
+        </Button>
+      )
+
+      expect(screen.getByRole('button')).toHaveAttribute('type')
+    })
   })
 })
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
index 2eb6660..30ee990 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
@@ -1211,6 +1211,31 @@ describe('Rendering', () => {
         expect(getComboboxButton()).not.toHaveAttribute('type')
       })
     })
+
+    it(
+      'should be possible to render a ComboboxButton using as={Fragment}',
+      suppressConsoleLogs(async () => {
+        render(
+          <Combobox>
+            <ComboboxInput />
+            <ComboboxButton as={Fragment}>
+              <button>Toggle</button>
+            </ComboboxButton>
+            <ComboboxOptions>
+              <ComboboxOption value="a">Option A</ComboboxOption>
+              <ComboboxOption value="b">Option B</ComboboxOption>
+              <ComboboxOption value="c">Option C</ComboboxOption>
+            </ComboboxOptions>
+          </Combobox>
+        )
+
+        assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })
+
+        await click(getComboboxButton())
+
+        assertComboboxButton({ state: ComboboxState.Visible })
+      })
+    )
   })
 
   describe('Combobox.Options', () => {
diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
index 6b66268..635c87d 100644
--- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
+++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
@@ -1,5 +1,5 @@
 import { render, waitFor } from '@testing-library/react'
-import React, { Suspense, createElement, useEffect, useRef } from 'react'
+import React, { Fragment, Suspense, createElement, useEffect, useRef } from 'react'
 import {
   DisclosureState,
   assertActiveElement,
@@ -439,6 +439,28 @@ describe('Rendering', () => {
         expect(getDisclosureButton()).not.toHaveAttribute('type')
       })
     })
+
+    it(
+      'should be possible to render a DisclosureButton using as={Fragment}',
+      suppressConsoleLogs(async () => {
+        render(
+          <Disclosure>
+            <DisclosureButton as={Fragment}>
+              <button>Toggle</button>
+            </DisclosureButton>
+            <DisclosurePanel>Contents</DisclosurePanel>
+          </Disclosure>
+        )
+
+        assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
+        assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+        await click(getDisclosureButton())
+
+        assertDisclosureButton({ state: DisclosureState.Visible })
+        assertDisclosurePanel({ state: DisclosureState.Visible })
+      })
+    )
   })
 
   describe('Disclosure.Panel', () => {
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
index d75b01f..f606fc9 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
@@ -1,5 +1,5 @@
 import { render, waitFor } from '@testing-library/react'
-import React, { createElement, useEffect, useState } from 'react'
+import React, { Fragment, createElement, useEffect, useState } from 'react'
 import {
   ListboxMode,
   ListboxState,
@@ -760,6 +760,32 @@ describe('Rendering', () => {
         expect(getListboxButton()).not.toHaveAttribute('type')
       })
     })
+
+    it(
+      'should be possible to render a ListboxButton using as={Fragment}',
+      suppressConsoleLogs(async () => {
+        render(
+          <Listbox>
+            <ListboxButton as={Fragment}>
+              <button>Toggle</button>
+            </ListboxButton>
+            <ListboxOptions>
+              <ListboxOption value="a">Option A</ListboxOption>
+              <ListboxOption value="b">Option B</ListboxOption>
+              <ListboxOption value="c">Option C</ListboxOption>
+            </ListboxOptions>
+          </Listbox>
+        )
+
+        assertListboxButton({ state: ListboxState.InvisibleUnmounted })
+        assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+        await click(getListboxButton())
+
+        assertListboxButton({ state: ListboxState.Visible })
+        assertListbox({ state: ListboxState.Visible })
+      })
+    )
   })
 
   describe('Listbox.Options', () => {
diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx
index a57e748..a3ed4d2 100644
--- a/packages/@headlessui-react/src/components/menu/menu.test.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx
@@ -1,5 +1,5 @@
 import { render, waitFor } from '@testing-library/react'
-import React, { createElement, useEffect } from 'react'
+import React, { Fragment, createElement, useEffect } from 'react'
 import {
   MenuState,
   assertActiveElement,
@@ -306,6 +306,32 @@ describe('Rendering', () => {
         expect(getMenuButton()).not.toHaveAttribute('type')
       })
     })
+
+    it(
+      'should be possible to render a MenuButton using as={Fragment}',
+      suppressConsoleLogs(async () => {
+        render(
+          <Menu>
+            <MenuButton as={Fragment}>
+              <button>Toggle</button>
+            </MenuButton>
+            <MenuItems>
+              <MenuItem as="a">Item A</MenuItem>
+              <MenuItem as="a">Item B</MenuItem>
+              <MenuItem as="a">Item C</MenuItem>
+            </MenuItems>
+          </Menu>
+        )
+
+        assertMenuButton({ state: MenuState.InvisibleUnmounted })
+        assertMenu({ state: MenuState.InvisibleUnmounted })
+
+        await click(getMenuButton())
+
+        assertMenuButton({ state: MenuState.Visible })
+        assertMenu({ state: MenuState.Visible })
+      })
+    )
   })
 
   describe('Menu.Items', () => {