Search code examples
vitestpreact

Vitest with Preact: Adding forwardRef prevents mocked useState hook from firing


Component without forwardRef passes the test

I have a working Preact component and a working test in Vitest. Working minimized samples:

Component

import { ChangeEvent } from 'preact/compat';

export function DateInput({
  value,
  setValue,
  label,
}: {
  value: string;
  setValue: (value: string) => void;
  label: string;
}) {
  const storeValue = (e: ChangeEvent): void => {
    //  console.log('event fired'); --> Fires
    const elem: HTMLInputElement = e.currentTarget as HTMLInputElement;
    setValue(elem.value);
  };

  return (
    <>
      <label htmlFor="test-id">{label}</label>
      <input type="date" value={value} id="test-id" onChange={storeValue} />
    </>
  );
}

Test (passing)

it.only('should update state when date is changed', async () => {
  const setValue = vi.fn();
  const { container } = render(
    <DateInput
      value={m.valueOutput1} // "2024-01-30"
      setValue={setValue}
      label={m.customLabel} // "Test label:"
    />
  );

  const htmlInput: HTMLInputElement | null = queryByLabelText(
    container as HTMLElement,
    m.customLabel
  );
  if (htmlInput) {
    await act(() => {
      fireEvent.change(htmlInput, { target: { value: m.valueOutput2 } }); // "2024-01-31"
    });
    await waitFor(() => {
      expect(setValue).toHaveBeenCalledOnce(); // Passing without forwardRef, failing with it
    });
  }
});

Adding forwardRef makes the exact same test not pass anymore

Updating the test as seen below makes the test fail with message:

AssertionError: expected "spy" to be called once, but got 0 times

    201|       await waitFor(() => {
    202|         expect(setValue).toHaveBeenCalledOnce();
       |                          ^
    203|       });

Component adjusted with forwardRef

import { Ref } from 'preact';
import { ForwardedRef, forwardRef, ChangeEvent } from 'preact/compat';

export const DateInput = forwardRef(function DateInput(
  {
    value,
    setValue,
    label,
  }: {
    value: string;
    setValue: (value: string) => void;
    label: string;
  },
  ref: ForwardedRef<HTMLElement>
) {
  const storeValue = (e: ChangeEvent): void => {
    //  console.log('event fired'); --> Does not fire anymore
    const elem: HTMLInputElement = e.currentTarget as HTMLInputElement;
    setValue(elem.value);
  };

  return (
    <>
      <label htmlFor="test-id">{label}</label>
      <input
        type="date"
        value={value}
        id="test-id"
        onChange={storeValue}
        ref={ref as Ref<HTMLInputElement>}
      />
    </>
  );
});

What I tried so far

  • Googling for 4 hours didn't yield anything related exactly to my problem.
  • I tried both with and without async functions in tests (act and waitFor) but nothing changed.
  • I verified (via logging all the steps) that all the operations in the test execute in the same order as written.
  • Adding a React.createRef() reference to the test component didn't solve my issue either.
  • It seems the onChange event does not trigger at all while using forwardRef because I never reach it, even when putting a breakpoint directly inside the event:
...
      <input                      // <-- breakpoint here does trigger
        type="date"
        value={value}
        id="test-id"
        onChange={e => {
          storeValue(e);          // <-- breakpoint here does not trigger
        }}
        ref={ref as Ref<HTMLInputElement>}
      />
...

Solution

  • This is an issue with preact-testing-library. Preact's preact/compat library causes all events to be translated into names used in React (where they differ), but the testing library does not do this translation. There are in fact 16 events which are affected by this (mostly animation, composition and touch events).

    To fix this immediately, just needed to replace onChange to onInput. This does not solve the underlying issue though.

    https://preactjs.com/guide/v10/differences-to-react#use-oninput-instead-of-onchange