tailwindlabs / headlessui

Completely unstyled, fully accessible UI components, designed to integrate beautifully with Tailwind CSS.

Home Page:https://headlessui.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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

crissadriana opened this issue · comments

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?

+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 to select options.

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

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:

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

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.

Hey! Small update: this is a bug in Headless UI, and #3357 will solve the issue which means that the reproduction provided here #3294 (comment) 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!