Repository: tailwindlabs/headlessui (TypeScript)

Issue:
Cannot test combobox rendered in portal with react-testing-library (headlessui v2)
<!-- Please provide all of the information requested below. We're a small team and without all of this information it's not possible for us to help and your bug report will be closed. -->

**What package within Headless UI are you using?**
@headlessui/react 

**What version of that package are you using?**

v2.0.4

**Describe your issue**

After upgrading to version 2, the combobox using anchor opens in a portal on the body and the integration tests using react-testing-library are now failing. For example, I'm trying to test a component where I should render the combobox but when I have to check the contents of it, the test is failing because it doesn't find the list of options. I have tried to add the body as a wrapper for the tested component but that doesn't fix it. Do you have any suggestions on that?

Popover using anchor prop in PopoverPanel hangs unit test
<!-- Please provide all of the information requested below. We're a small team and without all of this information it's not possible for us to help and your bug report will be closed. -->

**What package within Headless UI are you using?**
@headlessui/react

**What version of that package are you using?**
2.1

Adding this unit test to popover.test.tsx reproduces the problem

```
describe('Popover', () => {
    it(
      'should open panel with anchor prop',
      suppressConsoleLogs(async () => {
        render(
          <Popover>
            <PopoverButton>Trigger</PopoverButton>
            <PopoverPanel anchor="bottom">Panel open</PopoverPanel>
          </Popover>
        )

        assertPopoverButton({
          state: PopoverState.InvisibleUnmounted,
          attributes: { id: 'headlessui-popover-button-1' },
        })
        assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })

        await click(getPopoverButton())

        assertPopoverButton({
          state: PopoverState.Visible,
          attributes: { id: 'headlessui-popover-button-1' },
        })
        assertPopoverPanel({ state: PopoverState.Visible, textContent: 'Panel open' })
      })
    )
   
```

The problem seems to be within `useFixScrollingPixel` while setting the max height which resolves to NaN and this method is infinitely called.
<img width="580" alt="image" src="https://github.com/tailwindlabs/headlessui/assets/46825740/74fd6174-7df2-4986-8ce5-9aabbac60124">

`maxHeight: "min(var(--anchor-max-height, 100vh), 0px)"`




Discussion:
+1

Happens with `listbox` as well
Hey!

Can you share a minimal reproduction repo that shows how you are testing the component?
Hey @RobinMalfait, this is one of the most minimal example I can do
made with create-react-app, matching the versions in my package.json

`npm run test` and watch is hang

https://github.com/optimistic-updt/repro-headless-jest-portal

Thank you for looking into it
@RobinMalfait Sorry for the late reply, I was off the past week. Similarly to what @optimistic-updt shared already, I'm trying to test a component (Example.tsx) using combobox.

Example.tsx

```
import { forwardRef, useState } from "react";
import {
  Combobox,
  ComboboxButton,
  ComboboxInput,
  ComboboxOption,
  ComboboxOptions,
} from "@headlessui/react";

const people = [
  { id: 1, name: "Durward Reynolds" },
  { id: 2, name: "Kenton Towne" },
  { id: 3, name: "Therese Wunsch" },
  { id: 4, name: "Benedict Kessler" },
  { id: 5, name: "Katelyn Rohan" },
];

const MyCustomButton = forwardRef(function (props, ref) {
  return <button className="..." ref={ref} {...props} />;
});

function Example() {
  const [selectedPeople, setSelectedPeople] = useState([people[0], people[1]]);
  const [query, setQuery] = useState("");

  const filteredPeople =
    query === ""
      ? people
      : people.filter((person) => {
          return person.name.toLowerCase().includes(query.toLowerCase());
        });

  return (
    <Combobox
      multiple
      value={selectedPeople}
      onChange={setSelectedPeople}
      onClose={() => setQuery("")}
    >
      {selectedPeople.length > 0 && (
        <ul>
          {selectedPeople.map((person) => (
            <li key={person.id}>{person.name}</li>
          ))}
        </ul>
      )}
      <ComboboxInput
        aria-label="Assignees"
        onChange={(event) => setQuery(event.target.value)}
      />
      <ComboboxButton as={MyCustomButton}>Open</ComboboxButton>
      <ComboboxOptions anchor="bottom" className="border empty:invisible">
        {filteredPeople.map((person) => (
          <ComboboxOption
            key={person.id}
            value={person}
            className="data-[focus]:bg-blue-100"
          >
            {person.name}
          </ComboboxOption>
        ))}
      </ComboboxOptions>
    </Combobox>
  );
}
```

Example.test.tsx

```
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, vi } from "vitest";

describe("Example", () => {
  it("renders correctly the example options", async () => {
    render(<Example />);

    const dropdownButton = await screen.findByRole("button");
      fireEvent.click(dropdownButton);
      expect(screen.getByText("Katelyn Rohan")).toBeInTheDocument(); // Failing this line as the combobox options are not rendered
  });
});

```
Appreciate your help!


Using `userEvent.selectOptions()` worked for me. Note that Headless UI uses [mousedown instead of click](https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/combobox/combobox.tsx#L1871) to select options.

```ts
screen.getByRole("input").focus();
const option = (await screen.getByRole("option", { name: "Some option" }));
await userEvent.selectOptions(screen.getByRole("listbox"), selectedOption);
```
I’m also facing a similar issue to the above. 
When I am testing with no anchor passed to the options container the test works fine. When I add the anchor back in, the test hangs indefinitely. 

There are warnings in the logs for NaN being an invalid value for the ‘left’ css style property, originating from ‘InternalPortalFn2’
> Using `userEvent.selectOptions()` worked for me. Note that Headless UI uses [mousedown instead of click](https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/combobox/combobox.tsx#L1871) to select options.
> 
> ```ts
> screen.getByRole("input").focus();
> const option = (await screen.getByRole("option", { name: "Some option" }));
> await userEvent.selectOptions(screen.getByRole("listbox"), selectedOption);
> ```

Are you making use of the anchor property in the options container?
Thanks @JordanVincent Using userEvent works for me too only if I don't pass the anchor prop to ComboboxOptions. If I pass the anchor (as I need to) the test is failing.
Hmm, it works on my end with `anchor`:

```tsx
<ComboboxOptions
    static={true}
    anchor={{ to: "bottom start", gap: 8, padding: 8 }}
>...</ComboboxOptions>
```
Make sure the options are shown. You can inspect the DOM with `screen.debug()`.
Ok, so my original solution was flaky. But I was able to fix it that way:

```ts
const requestAnimationFrameMock = jest.spyOn(window, "requestAnimationFrame").mockImplementation(setImmediate as any);
const cancelAnimationFrameMock = jest.spyOn(window, "cancelAnimationFrame").mockImplementation(clearImmediate as any);

screen.getByRole("input").focus();
await screen.getByRole("option", { name: "Some option" });
await userEvent.selectOptions(screen.getByRole("listbox"), selectedOption);
screen.getByRole("input").blur();

requestAnimationFrameMock.mockRestore();
cancelAnimationFrameMock.mockRestore();
```

When `immediate` is set to `true`, [it refocuses the input](https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/combobox/combobox.tsx#L1901). This reopens the dropdown but because it's using `requestAnimationTimeframe` it's hard to get the timing right, leading to flakiness. Mocking `requestAnimationTimeframe` fixes the timing issue and calling `.blur()` closes the dropdown. Headless UI's does something similar in [their tests](https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/combobox/combobox.test.tsx#L66).
Hey! Small update: this is a bug in Headless UI, and #3357 will solve the issue which means that the reproduction provided here https://github.com/tailwindlabs/headlessui/issues/3294#issuecomment-2187849495 will just work without any changes (apart from a version bump once the PR is ready) 
@JordanVincent thanks for the message - I got it working with:

```
fireEvent.focus(screen.getByRole("combobox")); // focus the combobox input
screen.getByRole("option", { name: "Katelyn Rohan" }); // get an option from the list
```

Also, as you pointed out, the `immediate` was also needed so I just passed it to the component:
```
<Combobox
      multiple
      value={selectedPeople}
      onChange={setSelectedPeople}
      onClose={() => setQuery("")}
      immediate
    >.....</Combobox>
```

Thanks very much for your help!
This should be fixed by #3357, and will be available in the next release.

You can already try it using:
- `npm install @headlessui/react@insiders`.
> This should be fixed by #3357, and will be available in the next release.
> 
> You can already try it using:
> 
> * `npm install @headlessui/react@insiders`.

This now works for me! Thank you for the quick turnaround!


Hey! First of all, thank you very much for the test that reproduces the issue, this was very helpful, as well as your initial debugging here!

This should be fixed by #3357, and will be available in the next release.

You can already try it using:
- `npm install @headlessui/react@insiders`.



Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index b4a58bfff3..07b1f16776 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Fix prematurely added anchoring styles on `ListboxOptions` ([#3337](https://github.com/tailwindlabs/headlessui/pull/3337))
 - Ensure `unmount` on `Dialog` works in combination with the `transition` prop on `DialogBackdrop` and `DialogPanel` components ([#3352](https://github.com/tailwindlabs/headlessui/pull/3352))
 - Fix crash in `Combobox` component when in `virtual` mode when options are empty ([#3356](https://github.com/tailwindlabs/headlessui/pull/3356))
+- Fix hanging tests when using `anchor` prop ([#3357](https://github.com/tailwindlabs/headlessui/pull/3357))
 
 ## [2.1.1] - 2024-06-26
 
diff --git a/packages/@headlessui-react/src/internal/floating.tsx b/packages/@headlessui-react/src/internal/floating.tsx
index 6baaa432df..216e1f9f87 100644
--- a/packages/@headlessui-react/src/internal/floating.tsx
+++ b/packages/@headlessui-react/src/internal/floating.tsx
@@ -375,9 +375,16 @@ function useFixScrollingPixel(element: HTMLElement | null) {
     if (!element) return
 
     let observer = new MutationObserver(() => {
-      let maxHeight = element.style.maxHeight
-      if (parseFloat(maxHeight) !== parseInt(maxHeight)) {
-        element.style.maxHeight = `${Math.ceil(parseFloat(maxHeight))}px`
+      let maxHeight = window.getComputedStyle(element).maxHeight
+
+      let maxHeightFloat = parseFloat(maxHeight)
+      if (isNaN(maxHeightFloat)) return
+
+      let maxHeightInt = parseInt(maxHeight)
+      if (isNaN(maxHeightInt)) return
+
+      if (maxHeightFloat !== maxHeightInt) {
+        element.style.maxHeight = `${Math.ceil(maxHeightFloat)}px`
       }
     })
 
diff --git a/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx b/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx
index 0d845b098a..de62f6945f 100644
--- a/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx
+++ b/playgrounds/react/pages/listbox/listbox-with-pure-tailwind.tsx
@@ -1,4 +1,4 @@
-import { Listbox, Transition } from '@headlessui/react'
+import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
 import { useEffect, useState } from 'react'
 
 let people = [
@@ -26,20 +26,12 @@ export default function Home() {
     <div className="flex h-full w-screen justify-center bg-gray-50 p-12">
       <div className="mx-auto w-full max-w-xs">
         <div className="space-y-1">
-          <Listbox
-            value={active}
-            onChange={(value) => {
-              console.log('value:', value)
-              setActivePerson(value)
-            }}
-          >
-            <Listbox.Label className="block text-sm font-medium leading-5 text-gray-700">
-              Assigned to
-            </Listbox.Label>
+          <Listbox value={active} onChange={setActivePerson}>
+            <Label className="block text-sm font-medium leading-5 text-gray-700">Assigned to</Label>
 
             <div className="relative">
               <span className="inline-block w-full rounded-md shadow-sm">
-                <Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
+                <ListboxButton className="relative w-full cursor-default rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out sm:text-sm sm:leading-5">
                   <span className="block truncate">{active}</span>
                   <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
                     <svg
@@ -56,42 +48,35 @@ export default function Home() {
                       />
                     </svg>
                   </span>
-                </Listbox.Button>
+                </ListboxButton>
               </span>
 
-              <Transition
-                enter="transition duration-500 ease-out"
-                enterFrom="transform scale-95 opacity-0"
-                enterTo="transform scale-100 opacity-100"
-                leave="transition duration-500 ease-out"
-                leaveFrom="transform scale-100 opacity-100"
-                leaveTo="transform scale-95 opacity-0"
+              <ListboxOptions
+                anchor="bottom"
+                transition
+                className="w-[var(--button-width)] overflow-auto rounded-md border border-gray-300 bg-white py-1 text-base leading-6 shadow-lg transition duration-200 ease-out [--anchor-gap:theme(spacing.1)] [--anchor-max-height:theme(spacing.60)] focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0 sm:text-sm sm:leading-5"
               >
-                <div className="absolute mt-1 w-full rounded-md bg-white shadow-lg">
-                  <Listbox.Options className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
-                    {people.map((name) => (
-                      <Listbox.Option
-                        key={name}
-                        value={name}
-                        className="group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900 focus:outline-none data-[active]:bg-indigo-600 data-[active]:text-white"
-                      >
-                        <span className="block truncate font-normal group-data-[selected]:font-semibold">
-                          {name}
-                        </span>
-                        <span className="absolute inset-y-0 right-0 hidden items-center pr-4 text-indigo-600 group-data-[selected]:flex group-data-[active]:text-white">
-                          <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
-                            <path
-                              fillRule="evenodd"
-                              d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
-                              clipRule="evenodd"
-                            />
-                          </svg>
-                        </span>
-                      </Listbox.Option>
-                    ))}
-                  </Listbox.Options>
-                </div>
-              </Transition>
+                {people.map((name) => (
+                  <ListboxOption
+                    key={name}
+                    value={name}
+                    className="group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900 focus:outline-none data-[active]:bg-indigo-600 data-[active]:text-white"
+                  >
+                    <span className="block truncate font-normal group-data-[selected]:font-semibold">
+                      {name}
+                    </span>
+                    <span className="absolute inset-y-0 right-0 hidden items-center pr-4 text-indigo-600 group-data-[selected]:flex group-data-[active]:text-white">
+                      <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
+                        <path
+                          fillRule="evenodd"
+                          d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+                          clipRule="evenodd"
+                        />
+                      </svg>
+                    </span>
+                  </ListboxOption>
+                ))}
+              </ListboxOptions>
             </div>
           </Listbox>
         </div>


Test patch:
diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx
index 1ca6e7d747..85d00d0f71 100644
--- a/packages/@headlessui-react/src/components/popover/popover.test.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx
@@ -804,6 +804,26 @@ describe('Rendering', () => {
         assertActiveElement(getByText('restorable'))
       })
     )
+
+    it(
+      'should be possible to use the `anchor` prop on the `PopoverPanel`',
+      suppressConsoleLogs(async () => {
+        render(
+          <Popover>
+            <PopoverButton>Trigger</PopoverButton>
+            <PopoverPanel anchor="bottom">Panel open</PopoverPanel>
+          </Popover>
+        )
+
+        assertPopoverButton({ state: PopoverState.InvisibleUnmounted })
+        assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+        await click(getPopoverButton())
+
+        assertPopoverButton({ state: PopoverState.Visible })
+        assertPopoverPanel({ state: PopoverState.Visible })
+      })
+    )
   })
 
   describe('Multiple `Popover.Button` warnings', () => {