headlessui icon indicating copy to clipboard operation
headlessui copied to clipboard

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

Open crissadriana opened this issue 1 year ago • 3 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?

crissadriana avatar Jun 17 '24 10:06 crissadriana

+1

Happens with listbox as well

optimistic-updt avatar Jun 24 '24 02:06 optimistic-updt

Hey!

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

RobinMalfait avatar Jun 24 '24 10:06 RobinMalfait

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

optimistic-updt avatar Jun 25 '24 02:06 optimistic-updt

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

crissadriana avatar Jul 01 '24 11:07 crissadriana

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);

JordanVincent avatar Jul 02 '24 00:07 JordanVincent

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 avatar Jul 02 '24 08:07 Odysseus14

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?

Odysseus14 avatar Jul 02 '24 08:07 Odysseus14

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.

crissadriana avatar Jul 02 '24 14:07 crissadriana

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 avatar Jul 02 '24 16:07 JordanVincent

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.

JordanVincent avatar Jul 02 '24 22:07 JordanVincent

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)

RobinMalfait avatar Jul 03 '24 13:07 RobinMalfait

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

crissadriana avatar Jul 03 '24 14:07 crissadriana

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.

RobinMalfait avatar Jul 03 '24 20:07 RobinMalfait

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!

Odysseus14 avatar Jul 04 '24 10:07 Odysseus14