tailwindlabs / headlessui

Completely unstyled, fully accessible UI components, designed to integrate beautifully with Tailwind CSS.
https://headlessui.com
MIT License
25.02k stars 1.03k forks source link

Cannot test combobox rendered in portal with react-testing-library (headlessui v2) #3294

Open crissadriana opened 2 weeks ago

crissadriana commented 2 weeks ago

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?

optimistic-updt commented 1 week ago

+1

Happens with listbox as well

RobinMalfait commented 1 week ago

Hey!

Can you share a minimal reproduction repo that shows how you are testing the component?

optimistic-updt commented 1 week ago

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

crissadriana commented 1 day ago

@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!

JordanVincent commented 1 day ago

Using userEvent.selectOptions() worked for me. Note that Headless UI uses mousedown instead of click to select options.

screen.getByRole("input").focus();
const option = (await screen.getByRole("option", { name: "Some option" }));
await userEvent.selectOptions(screen.getByRole("listbox"), selectedOption);
Odysseus14 commented 16 hours ago

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’

Odysseus14 commented 16 hours ago

Using userEvent.selectOptions() worked for me. Note that Headless UI uses mousedown instead of click to select options.

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?

crissadriana commented 9 hours ago

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.

JordanVincent commented 7 hours ago

Hmm, it works on my end with anchor:

<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().

JordanVincent commented 2 hours ago

Ok, so my original solution was flaky. But I was able to fix it that way:

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. 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.