React-hooks-testing-library: Async callback was not invoked within the 5000ms timeout with waitForNextUpdate()

Created on 8 Nov 2019  路  19Comments  路  Source: testing-library/react-hooks-testing-library

Helo! I could not solve the problem in 8 hours. Need help :)

Hook:

const useQuery = <T extends Object>(query: string, options: OptionsType = {}): ReturnType<T> => {
  const initialState: InitialStateType<T> = { isFetching: false, data: null, errors: null }
  const [ state, setState ] = useState(initialState)

  const updatedOptions = useDeepMemo(() => ({
    ...options, query
  }), [ options, query ])

  const handleFetch = useCallback(() => {
    const { variables, name = 'noName' } = updatedOptions

    setState({ data: null, errors: null, isFetching: true })

    return request.post(`graphql?name=${name}`, {
      body: {
        query,
        variables,
      },
    })
      .then(
        (data: T) => {
          console.log('RESOLVE ok') // Log for test
          setState(() => ({ data, isFetching: false, errors: null, }))
        },
        ({ errors }) => setState(() => ({ errors, isFetching: false, data: null })),
      )
  }, [ updatedOptions ])

  useEffect(() => {
    handleFetch()
  }, [ updatedOptions ])

  console.log('RENDER hook') // Log for test

  return { ...state, fetch: handleFetch }
}

Test:

import { renderHook } from "@testing-library/react-hooks"

import useQuery from './useQuery'


jest.mock("sb-request", () => ({
  post: jest.fn(async () => new Promise((r) => {
    console.log('POST ok') // Log for test
    setTimeout(() => r('success'), 1000)
  })),
}))

describe("sb-hooks/useQuery", () => {

  it("should return data after fetching", async () => {
    const { result, waitForNextUpdate } = renderHook(() => useQuery('gql query', {
      variables: {},
      name: '1'
    }))

    await waitForNextUpdate();

    expect(result.current.data).toEqual('success');
  })
})

Console screen: https://prnt.sc/puclgu

"@testing-library/react-hooks": "2.0.3", (and used 3.2.1)
"react-test-renderer": "16.8.6",
"react": 16.8.6

No idea why waitForNextUpdate not see render(

question

All 19 comments

Can you add a log just before the waitForNextUpdate call and see where it is in the sequence of log messages?

Yes. Check screen pls https://prnt.sc/pugamr

Hmm, Ok.

Can you show me what useDeepMemo looks like so I can run this myself? Or if you can get it failing in a codesandbox, that would be ideal.

Yeah. I copied it with apollo:

import { useRef } from 'react'
import { isEqual } from 'lodash'


const useDeepMemo =<TKey, TValue>(memoFn: () => TValue, key: TKey): TValue => {
  const ref = useRef<{ key: TKey; value: TValue }>();

  if (!ref.current || !isEqual(key, ref.current.key)) {
    ref.current = { key, value: memoFn() };
  }

  return ref.current.value;
}


export default useDeepMemo

https://codesandbox.io/s/friendly-leakey-9hpby?fontsize=14
I created a simple hook, without dependencies. It doesn't work too.

Hi @Cast0001,

Thanks for that.

Updating react (and react-dom and react-test-renderer) to^16.9.0 and @testing-library/react-hooks to ^3.0.0 allows the test to pass -> https://codesandbox.io/s/happy-mcnulty-l8ngu

I'm still curious why is didn't just print the async act warning and still pass the test in the older versions which is what I would have expected. It may take me longer to get to the bottom of that, so if upgrading those versions is an option for you, please do so.

Ok, so wasn't that bad to track down. I actually got my versions confused as to when we started requiring [email protected] as the minimum peer dependency.

TL;DR; reverting @testing-library/react-hooks to ^1.0.0 also passes the test, but with warnings about updates not wrapped in act (what the [email protected] release enabled us to fix) -> https://codesandbox.io/s/zen-waterfall-blq6f

The longer version isn't much longer to be honest. In version 2.0.0 we wrapped waitForNextUpdate in the new async act utility which allows you to await the promise and the updates get batched in the same act handler, removing the warning. Unfortunately, the previously supported act(() => {}) calls also return a "promise like" (presumably for debugging purposes if the name in the type definitions mean anything), which is what your tests ends up awaiting (I'm not sure if it never resolves or how the promise like is intended to be used, but it definitely isn't waiting for your hook to rerender like you want it to.

So your options are to roll react (and friends) forward to ^16.9.0 (recommended) or roll @testing-library/react-hooks backwards to ^1.0.0 and live with the hideous warning it produces (not recommended).

Thank you very much! I will take your advice and update to ^16.9.0!

~It seems that this can happen with ^16.10.0 and "@testing-library/react-hooks": "^3.2.1",~

Update: turns out I was actually testing a default context that had no value set, so was hitting noops, so it seemed that my updates were not happening, but when I added my wrapper that included the real implementation of the context, my updates were working correctly.

@mpeyper , this issue is back again in the latest version of react-hooks-testing library.
Issue found in the following versions:

"react": "16.12.0",
"@testing-library/react-hooks": "3.2.1",

https://codesandbox.io/s/sleepy-currying-dl31j

@JayantDaruvuri your sandbox is not using the [email protected] (matching your react version). Updating let's the test pass.

@JayantDaruvuri your sandbox is not using the [email protected] (matching your react version). Updating let's the test pass.

I can still reproduce this issue 馃槩
image

But now react-dom doesn't match your react version.

Actually @JayantDaruvuri, I thought about this some more and the version of react-dom shouldn't affect your test (although it _should_ match the others in a real app).

I even confirmed my dependency suspicion by removing react-dom altogether and the test still passed after reloading the sandbox:

Screenshot_20191231-122741

Screenshot_20191231-122749

I'm not sure if you just needed to reload your sandbox (I've had issue when changing dependency versions) but the test passes for me with the same versions you've got specified:

Screenshot_20191231-122850

Screenshot_20191231-122901

I am having the same issue:

"react": "^16.14.0",
"react-dom": "^16.14.0",
"@testing-library/react-hooks": "^3.4.2",
"react-test-renderer": "^16.14.0"

My custom hook:

import { useEffect, useState } from 'react';

interface ImageDimensions {
  [imageId: string]: {
    width: number;
    height: number;
    imageURL: string;
  };
}

interface Image {
  imageId: string;
  imageURL: string;
  width?: number;
  height?: number;
}

const getImageDimensions = (images: Image[]) => {
  return images.reduce<ImageDimensions>((finalImages, currentImage) => {
    finalImages[currentImage.imageId] = {
      width: currentImage.width ?? 200,
      height: currentImage.height ?? 200,
      imageURL: currentImage.imageURL,
    };
    return finalImages;
  }, {});
};

export const usePreloadImages = (images: Image[]) => {
  const [loading, setLoading] = useState(true);
  const [imagesDimensions, setImagesDimensions] = useState<ImageDimensions>(
    () => getImageDimensions(images)
  );

  const cacheImages = async () => {
    const promises = await images.map((image) => {
      return new Promise(
        (
          resolve: (value: Image) => void,
          reject: (reason: string | Event) => void
        ) => {
          const img = new Image();

          img.src = image.imageURL!;
          img.onload = (e: Event) => {
            resolve({
              imageId: image.imageId,
              width: (e.currentTarget as HTMLImageElement).naturalWidth,
              height: (e.currentTarget as HTMLImageElement).naturalHeight,
              imageURL: image.imageURL
            });
          };
          img.onerror = (e: string | Event) => {
            reject(e);
          };
        }
      );
    });

    try {
      const results = await Promise.all(promises);
      setImagesDimensions(getImageDimensions(results));
      setLoading(false);
    } catch (err) {
      console.error(err);
    }
  };

  useEffect(() => {
    cacheImages();
  }, []);

  return { loading, imagesDimensions };
};

My test:

it('returns loading false when the promise resolves', async () => {
    const { result, waitForNextUpdate } = renderHook(() =>
      usePreloadImages([
        {
          imageId: 'image-id',
          imageURL: '/image-id.jpg',
        },
      ])
    );
    await waitForNextUpdate();
    expect(result.current.loading).toBe(false);
  });

Test is not passing with error Timeout - Async callback was not invoked within the 5000ms timeout

Hi @francesco-albanese,

What happens if you out a console log in the onload callback? Are you certain the promise is resolving?

Hi @mpeyper yes I am certain, when I test it in the browser the promise resolves. When I do the same thing in jest using renderHook , the async callback error pops up. It might be a problem with jsdom and Image constructor? https://github.com/jsdom/jsdom/issues/1816
Does anyone have any idea?

Sorry @francesco-albanese, I have been off on leave and catching up on this. Did you ever get to the bottom of it?

I imagine you are correct and there is an issue with loading the image in the test environment and as the catch block in the effect doesn't trigger an update, the waitForNextUpdate is never resolving.

@mpeyper I managed to solve the issue, here is my solution:

My custom hook:

import { useEffect, useState } from 'react';
import { preloadImages } from '../utils/preloadImages';

interface ImageDimensions {
  [imageId: string]: {
    width: number;
    height: number;
    imageURL: string;
  };
}

export interface ImageObject {
  imageId: string;
  imageURL: string;
  width?: number;
  height?: number;
}

const getImageDimensions = (images: ImageObject[]) => {
  return images.reduce<ImageDimensions>((finalImages, currentImage) => {
    finalImages[currentImage.imageId] = {
      width: currentImage.width ?? 200,
      height: currentImage.height ?? 200,
      imageURL: currentImage.imageURL,
    };
    return finalImages;
  }, {});
};

export const usePreloadImages = (images: ImageObject[]) => {
  if (!Array.isArray(images)) {
    throw new Error('Please provide an array of images');
  }

  const [loading, setLoading] = useState(true);
  const [imagesDimensions, setImagesDimensions] = useState<ImageDimensions>(
    () => getImageDimensions(images)
  );

  const cacheImages = async () => {
    try {
      const results = await preloadImages(images);
      setImagesDimensions(getImageDimensions(results));
      setLoading(false);
    } catch (err) {
      Promise.resolve(err);
    }
  };

  useEffect(() => {
    cacheImages();
  }, []);

  return { loading, imagesDimensions };
};

My new preloadImages util:

import { ImageObject } from '../hooks/usePreloadImages';

export const preloadImages = async (images: ImageObject[]) => {
  try {
    const promises = images.map((image) => {
      return new Promise(
        (
          resolve: (value: ImageObject) => void,
          reject: (reason: string | Event) => void
        ) => {
          const img = new Image();

          img.src = image.imageURL!;
          img.onload = (e: Event) => {
            resolve({
              imageId: image.imageId,
              width: (e.currentTarget as HTMLImageElement).naturalWidth,
              height: (e.currentTarget as HTMLImageElement).naturalHeight,
              imageURL: image.imageURL,
            });
          };
          img.onerror = (e: string | Event) => {
            reject(e);
          };
        }
      );
    });
    const result = await Promise.all(promises);
    return result;
  } catch (e) {
    throw new Error(e);
  }
};

My (finally) passing tests:

import { renderHook } from '@testing-library/react-hooks';

import { usePreloadImages } from './usePreloadImages';
import { preloadImages } from '../utils/preloadImages';

jest.mock('../utils/preloadImages');

describe('usePreloadImages', () => {
  beforeEach(() => {
    ((preloadImages as unknown) as jest.Mock).mockReset();
  });
  it('returns loading true and image with default dimensions when promise rejects', async () => {
    await ((preloadImages as unknown) as jest.Mock).mockRejectedValueOnce(
      'Error'
    );
    const { result } = renderHook(() =>
      usePreloadImages([
        {
          imageId: 'landing',
          imageURL: '/landing.jpg',
        },
      ])
    );

    expect(result.current.loading).toBe(true);
    expect(result.current.imagesDimensions).toEqual(
      expect.objectContaining({
        'landing': {
          imageURL: '/landing.jpg',
          width: 200,
          height: 200,
        },
      })
    );
  });

  it('returns loading false when the promise resolves', async () => {
    ((preloadImages as unknown) as jest.Mock).mockResolvedValue([
      {
        imageId: 'landing',
        imageURL: '/landing.jpg',
        width: 350,
        height: 700,
      },
    ]);
    const { result, waitForNextUpdate } = renderHook(() =>
      usePreloadImages([
        {
          imageId: 'landing',
          imageURL: '/landing.jpg',
        },
      ])
    );
    await waitForNextUpdate();
    expect(result.current.loading).toBe(false);
    expect(result.current.imagesDimensions).toEqual(
      expect.objectContaining({
        'proton-mail-landing': {
          imageURL: '/landing.jpg',
          width: 350,
          height: 700,
        },
      })
    );
  });
});

I ended up extracting the new Image() logic into its own util, that I can mock during tests.

Was this page helpful?
0 / 5 - 0 ratings