import { noop } from '@internal/listenerMiddleware/utils' import type { SubscriptionOptions } from '@internal/query/core/apiState' import type { SubscriptionSelectors } from '@internal/query/core/buildMiddleware/types' import { server } from '@internal/query/tests/mocks/server' import { countObjectKeys } from '@internal/query/utils/countObjectKeys' import { actionsReducer, setupApiStore, useRenderCounter, waitForFakeTimer, waitMs, withProvider, } from '@internal/tests/utils/helpers' import type { UnknownAction } from '@reduxjs/toolkit' import { configureStore, createListenerMiddleware, createSlice, } from '@reduxjs/toolkit' import { QueryStatus, createApi, fetchBaseQuery, skipToken, } from '@reduxjs/toolkit/query/react' import { act, fireEvent, render, renderHook, screen, waitFor, } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import { HttpResponse, http } from 'msw' import { useEffect, useState } from 'react' // Just setup a temporary in-memory counter for tests that `getIncrementedAmount`. // This can be used to test how many renders happen due to data changes or // the refetching behavior of components. let amount = 0 let nextItemId = 0 let refetchCount = 0 interface Item { id: number } const api = createApi({ baseQuery: async (arg: any) => { await waitForFakeTimer(150) if (arg?.body && 'amount' in arg.body) { amount += 1 } if (arg?.body && 'forceError' in arg.body) { return { error: { status: 500, data: null, }, } } if (arg?.body && 'listItems' in arg.body) { const items: Item[] = [] for (let i = 0; i < 3; i++) { const item = { id: nextItemId++ } items.push(item) } return { data: items } } return { data: arg?.body ? { ...arg.body, ...(amount ? { amount } : {}) } : {}, } }, tagTypes: ['IncrementedAmount'], endpoints: (build) => ({ getUser: build.query<{ name: string }, number>({ query: () => ({ body: { name: 'Timmy' }, }), }), getUserAndForceError: build.query<{ name: string }, number>({ query: () => ({ body: { forceError: true, }, }), }), getUserWithRefetchError: build.query<{ name: string }, number>({ queryFn: async (id) => { refetchCount += 1 if (refetchCount > 1) { return { error: true } as any } return { data: { name: 'Timmy' } } }, }), getIncrementedAmount: build.query<{ amount: number }, void>({ query: () => ({ url: '', body: { amount, }, }), providesTags: ['IncrementedAmount'], }), triggerUpdatedAmount: build.mutation({ queryFn: async () => { return { data: undefined } }, invalidatesTags: ['IncrementedAmount'], }), updateUser: build.mutation<{ name: string }, { name: string }>({ query: (update) => ({ body: update }), }), getError: build.query({ query: () => '/error', }), listItems: build.query({ serializeQueryArgs: ({ endpointName }) => { return endpointName }, query: ({ pageNumber }) => ({ url: `items?limit=1&offset=${pageNumber}`, body: { listItems: true, }, }), merge: (currentCache, newItems) => { currentCache.push(...newItems) }, forceRefetch: () => { return true }, }), queryWithDeepArg: build.query({ query: ({ param: { nested } }) => nested, serializeQueryArgs: ({ queryArgs }) => { return queryArgs.param.nested }, }), }), }) const listenerMiddleware = createListenerMiddleware() let actions: UnknownAction[] = [] const storeRef = setupApiStore( api, {}, { middleware: { prepend: [listenerMiddleware.middleware], }, }, ) let getSubscriptions: SubscriptionSelectors['getSubscriptions'] let getSubscriptionCount: SubscriptionSelectors['getSubscriptionCount'] beforeEach(() => { actions = [] listenerMiddleware.startListening({ predicate: () => true, effect: (action) => { actions.push(action) }, }) ;({ getSubscriptions, getSubscriptionCount } = storeRef.store.dispatch( api.internalActions.internal_getRTKQSubscriptions(), ) as unknown as SubscriptionSelectors) }) afterEach(() => { nextItemId = 0 amount = 0 listenerMiddleware.clearListeners() }) let getRenderCount: () => number = () => 0 describe('hooks tests', () => { describe('useQuery', () => { test('useQuery hook basic render count assumptions', async () => { function User() { const { isFetching } = api.endpoints.getUser.useQuery(1) getRenderCount = useRenderCounter() return (
{String(isFetching)}
) } render(, { wrapper: storeRef.wrapper }) // By the time this runs, the initial render will happen, and the query // will start immediately running by the time we can expect this expect(getRenderCount()).toBe(2) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(3) }) test('useQuery hook sets isFetching=true whenever a request is in flight', async () => { function User() { const [value, setValue] = useState(0) const { isFetching } = api.endpoints.getUser.useQuery(1, { skip: value < 1, }) getRenderCount = useRenderCounter() return (
{String(isFetching)}
) } render(, { wrapper: storeRef.wrapper }) expect(getRenderCount()).toBe(1) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) fireEvent.click(screen.getByText('Increment value')) // setState = 1, perform request = 2 await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(4) fireEvent.click(screen.getByText('Increment value')) // Being that nothing has changed in the args, this should never fire. expect(screen.getByTestId('isFetching').textContent).toBe('false') expect(getRenderCount()).toBe(5) // even though there was no request, the button click updates the state so this is an expected render }) test('useQuery hook sets isLoading=true only on initial request', async () => { let refetch: any, isLoading: boolean, isFetching: boolean function User() { const [value, setValue] = useState(0) ;({ isLoading, isFetching, refetch } = api.endpoints.getUser.useQuery( 2, { skip: value < 1, }, )) return (
{String(isLoading)}
{String(isFetching)}
) } render(, { wrapper: storeRef.wrapper }) // Being that we skipped the initial request on mount, this should be false await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) fireEvent.click(screen.getByText('Increment value')) // Condition is met, should load await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) // Make sure the original loading has completed. fireEvent.click(screen.getByText('Increment value')) // Being that we already have data, isLoading should be false await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) // We call a refetch, should still be `false` act(() => void refetch()) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) expect(screen.getByTestId('isLoading').textContent).toBe('false') }) test('useQuery hook sets isLoading and isFetching to the correct states', async () => { let refetchMe: () => void = () => {} function User() { const [value, setValue] = useState(0) getRenderCount = useRenderCounter() const { isLoading, isFetching, refetch } = api.endpoints.getUser.useQuery(22, { skip: value < 1 }) refetchMe = refetch return (
{String(isFetching)}
{String(isLoading)}
) } render(, { wrapper: storeRef.wrapper }) expect(getRenderCount()).toBe(1) expect(screen.getByTestId('isLoading').textContent).toBe('false') expect(screen.getByTestId('isFetching').textContent).toBe('false') fireEvent.click(screen.getByText('Increment value')) // renders: set state = 1, perform request = 2 // Condition is met, should load await waitFor(() => { expect(screen.getByTestId('isLoading').textContent).toBe('true') expect(screen.getByTestId('isFetching').textContent).toBe('true') }) // Make sure the request is done for sure. await waitFor(() => { expect(screen.getByTestId('isLoading').textContent).toBe('false') expect(screen.getByTestId('isFetching').textContent).toBe('false') }) expect(getRenderCount()).toBe(4) fireEvent.click(screen.getByText('Increment value')) // Being that we already have data and changing the value doesn't trigger a new request, only the button click should impact the render await waitFor(() => { expect(screen.getByTestId('isLoading').textContent).toBe('false') expect(screen.getByTestId('isFetching').textContent).toBe('false') }) expect(getRenderCount()).toBe(5) // We call a refetch, should set `isFetching` to true, then false when complete/errored act(() => void refetchMe()) await waitFor(() => { expect(screen.getByTestId('isLoading').textContent).toBe('false') expect(screen.getByTestId('isFetching').textContent).toBe('true') }) await waitFor(() => { expect(screen.getByTestId('isLoading').textContent).toBe('false') expect(screen.getByTestId('isFetching').textContent).toBe('false') }) expect(getRenderCount()).toBe(7) }) test('`isLoading` does not jump back to true, while `isFetching` does', async () => { const loadingHist: boolean[] = [], fetchingHist: boolean[] = [] function User({ id }: { id: number }) { const { isLoading, isFetching, status } = api.endpoints.getUser.useQuery(id) useEffect(() => { loadingHist.push(isLoading) }, [isLoading]) useEffect(() => { fetchingHist.push(isFetching) }, [isFetching]) return (
{status === QueryStatus.fulfilled && id}
) } let { rerender } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('1'), ) rerender() await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('2'), ) expect(loadingHist).toEqual([true, false]) expect(fetchingHist).toEqual([true, false, true, false]) }) test('`isSuccess` does not jump back false on subsequent queries', async () => { type LoadingState = { id: number isFetching: boolean isSuccess: boolean } const loadingHistory: LoadingState[] = [] function User({ id }: { id: number }) { const queryRes = api.endpoints.getUser.useQuery(id) useEffect(() => { const { isFetching, isSuccess } = queryRes loadingHistory.push({ id, isFetching, isSuccess }) }, [id, queryRes]) return (
{queryRes.status === QueryStatus.fulfilled && id}
) } let { rerender } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('1'), ) rerender() await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('2'), ) expect(loadingHistory).toEqual([ // Initial render(s) { id: 1, isFetching: true, isSuccess: false }, { id: 1, isFetching: true, isSuccess: false }, // Data returned { id: 1, isFetching: false, isSuccess: true }, // ID changed, there's an uninitialized cache entry. // IMPORTANT: `isSuccess` should not be false here. // We have valid data already for the old item. { id: 2, isFetching: true, isSuccess: true }, { id: 2, isFetching: true, isSuccess: true }, { id: 2, isFetching: false, isSuccess: true }, ]) }) test('isSuccess stays consistent if there is an error while refetching', async () => { type LoadingState = { id: number isFetching: boolean isSuccess: boolean isError: boolean } const loadingHistory: LoadingState[] = [] function Component({ id = 1 }) { const queryRes = api.endpoints.getUserWithRefetchError.useQuery(id) const { refetch, data, status } = queryRes useEffect(() => { const { isFetching, isSuccess, isError } = queryRes loadingHistory.push({ id, isFetching, isSuccess, isError }) }, [id, queryRes]) return (
{data?.name}
{status}
) } render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('name').textContent).toBe('Timmy'), ) fireEvent.click(screen.getByText('refetch')) await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('pending'), ) await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('rejected'), ) fireEvent.click(screen.getByText('refetch')) await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('pending'), ) await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('rejected'), ) expect(loadingHistory).toEqual([ // Initial renders { id: 1, isFetching: true, isSuccess: false, isError: false }, { id: 1, isFetching: true, isSuccess: false, isError: false }, // Data is returned { id: 1, isFetching: false, isSuccess: true, isError: false }, // Started first refetch { id: 1, isFetching: true, isSuccess: true, isError: false }, // First refetch errored { id: 1, isFetching: false, isSuccess: false, isError: true }, // Started second refetch // IMPORTANT We expect `isSuccess` to still be false, // despite having started the refetch again. { id: 1, isFetching: true, isSuccess: false, isError: false }, // Second refetch errored { id: 1, isFetching: false, isSuccess: false, isError: true }, ]) }) test('useQuery hook respects refetchOnMountOrArgChange: true', async () => { let data, isLoading, isFetching function User() { ;({ data, isLoading, isFetching } = api.endpoints.getIncrementedAmount.useQuery(undefined, { refetchOnMountOrArgChange: true, })) return (
{String(isLoading)}
{String(isFetching)}
{String(data?.amount)}
) } const { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('1'), ) unmount() render(, { wrapper: storeRef.wrapper }) // Let's make sure we actually fetch, and we increment expect(screen.getByTestId('isLoading').textContent).toBe('false') await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('2'), ) }) test('useQuery does not refetch when refetchOnMountOrArgChange: NUMBER condition is not met', async () => { let data, isLoading, isFetching function User() { ;({ data, isLoading, isFetching } = api.endpoints.getIncrementedAmount.useQuery(undefined, { refetchOnMountOrArgChange: 10, })) return (
{String(isLoading)}
{String(isFetching)}
{String(data?.amount)}
) } const { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('1'), ) unmount() render(, { wrapper: storeRef.wrapper }) // Let's make sure we actually fetch, and we increment. Should be false because we do this immediately // and the condition is set to 10 seconds expect(screen.getByTestId('isFetching').textContent).toBe('false') await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('1'), ) }) test('useQuery refetches when refetchOnMountOrArgChange: NUMBER condition is met', async () => { let data, isLoading, isFetching function User() { ;({ data, isLoading, isFetching } = api.endpoints.getIncrementedAmount.useQuery(undefined, { refetchOnMountOrArgChange: 0.5, })) return (
{String(isLoading)}
{String(isFetching)}
{String(data?.amount)}
) } const { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('1'), ) unmount() // Wait to make sure we've passed the `refetchOnMountOrArgChange` value await waitMs(510) render(, { wrapper: storeRef.wrapper }) // Let's make sure we actually fetch, and we increment await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('2'), ) }) test('refetchOnMountOrArgChange works as expected when changing skip from false->true', async () => { let data, isLoading, isFetching function User() { const [skip, setSkip] = useState(true) ;({ data, isLoading, isFetching } = api.endpoints.getIncrementedAmount.useQuery(undefined, { refetchOnMountOrArgChange: 0.5, skip, })) return (
{String(isLoading)}
{String(isFetching)}
{String(data?.amount)}
;
) } render(, { wrapper: storeRef.wrapper }) expect(screen.getByTestId('isLoading').textContent).toBe('false') expect(screen.getByTestId('amount').textContent).toBe('undefined') fireEvent.click(screen.getByText('change skip')) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('1'), ) }) test('refetchOnMountOrArgChange works as expected when changing skip from false->true with a cached query', async () => { // 1. we need to mount a skipped query, then toggle skip to generate a cached result // 2. we need to mount a skipped component after that, then toggle skip as well. should pull from the cache. // 3. we need to mount another skipped component, then toggle skip after the specified duration and expect the time condition to be satisfied let data, isLoading, isFetching function User() { const [skip, setSkip] = useState(true) ;({ data, isLoading, isFetching } = api.endpoints.getIncrementedAmount.useQuery(undefined, { skip, refetchOnMountOrArgChange: 0.5, })) return (
{String(isLoading)}
{String(isFetching)}
{String(data?.amount)}
;
) } let { unmount } = render(, { wrapper: storeRef.wrapper }) expect(screen.getByTestId('isFetching').textContent).toBe('false') // skipped queries do nothing by default, so we need to toggle that to get a cached result fireEvent.click(screen.getByText('change skip')) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => { expect(screen.getByTestId('amount').textContent).toBe('1') expect(screen.getByTestId('isFetching').textContent).toBe('false') }) unmount() await waitMs(100) // This will pull from the cache as the time criteria is not met. ;({ unmount } = render(, { wrapper: storeRef.wrapper, })) // skipped queries return nothing expect(screen.getByTestId('isFetching').textContent).toBe('false') expect(screen.getByTestId('amount').textContent).toBe('undefined') // toggle skip -> true... won't refetch as the time critera is not met, and just loads the cached values fireEvent.click(screen.getByText('change skip')) expect(screen.getByTestId('isFetching').textContent).toBe('false') expect(screen.getByTestId('amount').textContent).toBe('1') unmount() await waitMs(500) ;({ unmount } = render(, { wrapper: storeRef.wrapper, })) // toggle skip -> true... will cause a refetch as the time criteria is now satisfied fireEvent.click(screen.getByText('change skip')) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('2'), ) }) test(`useQuery refetches when query args object changes even if serialized args don't change`, async () => { const user = userEvent.setup() function ItemList() { const [pageNumber, setPageNumber] = useState(0) const { data = [] } = api.useListItemsQuery({ pageNumber, }) const renderedItems = data.map((item) => (
  • ID: {item.id}
  • )) return (
      {renderedItems}
    ) } render(, { wrapper: storeRef.wrapper }) await screen.findByText('ID: 0') await user.click(screen.getByText('Next Page')) await screen.findByText('ID: 3') }) test(`useQuery shouldn't call args serialization if request skipped`, async () => { expect(() => renderHook(() => api.endpoints.queryWithDeepArg.useQuery(skipToken), { wrapper: storeRef.wrapper, }), ).not.toThrow() }) test(`useQuery gracefully handles bigint types`, async () => { const user = userEvent.setup() function ItemList() { const [pageNumber, setPageNumber] = useState(0) const { data = [] } = api.useListItemsQuery({ pageNumber: BigInt(pageNumber), }) const renderedItems = data.map((item) => (
  • ID: {item.id}
  • )) return (
      {renderedItems}
    ) } render(, { wrapper: storeRef.wrapper }) await screen.findByText('ID: 0') await user.click(screen.getByText('Next Page')) await screen.findByText('ID: 3') }) describe('api.util.resetApiState resets hook', () => { test('without `selectFromResult`', async () => { const { result } = renderHook(() => api.endpoints.getUser.useQuery(5), { wrapper: storeRef.wrapper, }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) act(() => void storeRef.store.dispatch(api.util.resetApiState())) expect(result.current).toEqual( expect.objectContaining({ isError: false, isFetching: true, isLoading: true, isSuccess: false, isUninitialized: false, refetch: expect.any(Function), status: 'pending', }), ) }) test('with `selectFromResult`', async () => { const selectFromResult = vi.fn((x) => x) const { result } = renderHook( () => api.endpoints.getUser.useQuery(5, { selectFromResult }), { wrapper: storeRef.wrapper, }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) selectFromResult.mockClear() act(() => { storeRef.store.dispatch(api.util.resetApiState()) }) expect(selectFromResult).toHaveBeenNthCalledWith(1, { isError: false, isFetching: false, isLoading: false, isSuccess: false, isUninitialized: true, status: 'uninitialized', }) }) }) test('useQuery refetch method returns a promise that resolves with the result', async () => { const { result } = renderHook( () => api.endpoints.getIncrementedAmount.useQuery(), { wrapper: storeRef.wrapper, }, ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) const originalAmount = result.current.data!.amount const { refetch } = result.current let resPromise: ReturnType = null as any await act(async () => { resPromise = refetch() }) expect(resPromise).toBeInstanceOf(Promise) const res = await act(() => resPromise) expect(res.data!.amount).toBeGreaterThan(originalAmount) }) // See https://github.com/reduxjs/redux-toolkit/issues/4267 - Memory leak in useQuery rapid query arg changes test('Hook subscriptions are properly cleaned up when query is fulfilled/rejected', async () => { // This is imported already, but it seems to be causing issues with the test on certain matrixes function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } const pokemonApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), endpoints: (builder) => ({ getTest: builder.query({ async queryFn() { await new Promise((resolve) => setTimeout(resolve, 1000)) return { data: 'data!' } }, keepUnusedDataFor: 0, }), }), }) const storeRef = setupApiStore(pokemonApi, undefined, { withoutTestLifecycles: true, }) const checkNumQueries = (count: number) => { const cacheEntries = Object.keys(storeRef.store.getState().api.queries) const queries = cacheEntries.length expect(queries).toBe(count) } let i = 0 function User() { const [fetchTest, { isFetching, isUninitialized }] = pokemonApi.endpoints.getTest.useLazyQuery() return (
    {String(isUninitialized)}
    {String(isFetching)}
    ) } render(, { wrapper: storeRef.wrapper }) fireEvent.click(screen.getByTestId('fetchButton')) fireEvent.click(screen.getByTestId('fetchButton')) fireEvent.click(screen.getByTestId('fetchButton')) checkNumQueries(3) await act(async () => { await delay(1500) }) // There should only be one stored query once they have had time to resolve checkNumQueries(1) }) // See https://github.com/reduxjs/redux-toolkit/issues/3182 test('Hook subscriptions are properly cleaned up when changing skip back and forth', async () => { const pokemonApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), endpoints: (builder) => ({ getPokemonByName: builder.query({ queryFn: (name: string) => ({ data: null }), keepUnusedDataFor: 1, }), }), }) const storeRef = setupApiStore(pokemonApi, undefined, { withoutTestLifecycles: true, }) const checkNumSubscriptions = (arg: string, count: number) => { const subscriptions = getSubscriptions() const cacheKeyEntry = subscriptions[arg] if (cacheKeyEntry) { const subscriptionCount = Object.keys(cacheKeyEntry) //getSubscriptionCount(arg) expect(subscriptionCount).toBe(count) } } // 1) Initial state: an active subscription const { rerender, unmount } = renderHook( ([arg, options]: Parameters< typeof pokemonApi.useGetPokemonByNameQuery >) => pokemonApi.useGetPokemonByNameQuery(arg, options), { wrapper: storeRef.wrapper, initialProps: ['a'], }, ) await act(async () => { await waitMs(1) }) // 2) Set the current subscription to `{skip: true} rerender(['a', { skip: true }]) // 3) Change _both_ the cache key _and_ `{skip: false}` at the same time. // This causes the `subscriptionRemoved` check to be `true`. rerender(['b']) // There should only be one active subscription after changing the arg checkNumSubscriptions('b', 1) // 4) Re-render with the same arg. // This causes the `subscriptionRemoved` check to be `false`. // Correct behavior is this does _not_ clear the promise ref, // so rerender(['b']) // There should only be one active subscription after changing the arg checkNumSubscriptions('b', 1) await act(async () => { await waitMs(1) }) unmount() await act(async () => { await waitMs(1) }) // There should be no subscription entries left over after changing // cache key args and swapping `skip` on and off checkNumSubscriptions('b', 0) const finalSubscriptions = getSubscriptions() for (const cacheKeyEntry of Object.values(finalSubscriptions)) { expect(Object.values(cacheKeyEntry!).length).toBe(0) } }) test('Hook subscription failures do not reset isLoading state', async () => { const states: boolean[] = [] function Parent() { const { isLoading } = api.endpoints.getUserAndForceError.useQuery(1) // Collect loading states to verify that it does not revert back to true. states.push(isLoading) // Parent conditionally renders child when loading. if (isLoading) return null return } function Child() { // Using the same args as the parent api.endpoints.getUserAndForceError.useQuery(1) return null } render(, { wrapper: storeRef.wrapper }) expect(states).toHaveLength(2) // Allow at least three state effects to hit. // Trying to see if any [true, false, true] occurs. await act(async () => { await waitForFakeTimer(150) }) expect(states).toHaveLength(4) await act(async () => { await waitForFakeTimer(150) }) expect(states).toHaveLength(5) await act(async () => { await waitForFakeTimer(150) }) expect(states).toHaveLength(5) // Find if at any time the isLoading state has reverted // E.G.: `[..., true, false, ..., true]` // ^^^^ ^^^^^ ^^^^ const firstTrue = states.indexOf(true) const firstFalse = states.slice(firstTrue).indexOf(false) const revertedState = states.slice(firstFalse).indexOf(true) expect( revertedState, `Expected isLoading state to never revert back to true but did after ${revertedState} renders...`, ).toBe(-1) }) describe('Hook middleware requirements', () => { const consoleErrorSpy = vi .spyOn(console, 'error') .mockImplementation(noop) afterEach(() => { consoleErrorSpy.mockClear() }) afterAll(() => { consoleErrorSpy.mockRestore() }) test('Throws error if middleware is not added to the store', async () => { const store = configureStore({ reducer: { [api.reducerPath]: api.reducer, }, }) const doRender = () => { renderHook(() => api.endpoints.getIncrementedAmount.useQuery(), { wrapper: withProvider(store), }) } expect(doRender).toThrowError( /Warning: Middleware for RTK-Query API at reducerPath "api" has not been added to the store/, ) }) }) }) describe('useLazyQuery', () => { let data: any afterEach(() => { data = undefined }) let getRenderCount: () => number = () => 0 test('useLazyQuery does not automatically fetch when mounted and has undefined data', async () => { function User() { const [fetchUser, { data: hookData, isFetching, isUninitialized }] = api.endpoints.getUser.useLazyQuery() getRenderCount = useRenderCounter() data = hookData return (
    {String(isUninitialized)}
    {String(isFetching)}
    ) } render(, { wrapper: storeRef.wrapper }) expect(getRenderCount()).toBe(1) await waitFor(() => expect(screen.getByTestId('isUninitialized').textContent).toBe('true'), ) await waitFor(() => expect(data).toBeUndefined()) fireEvent.click(screen.getByTestId('fetchButton')) expect(getRenderCount()).toBe(2) await waitFor(() => expect(screen.getByTestId('isUninitialized').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(3) fireEvent.click(screen.getByTestId('fetchButton')) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(5) }) test('useLazyQuery accepts updated subscription options and only dispatches updateSubscriptionOptions when values are updated', async () => { let interval = 1000 function User() { const [options, setOptions] = useState() const [fetchUser, { data: hookData, isFetching, isUninitialized }] = api.endpoints.getUser.useLazyQuery(options) getRenderCount = useRenderCounter() data = hookData return (
    {String(isUninitialized)}
    {String(isFetching)}
    ) } render(, { wrapper: storeRef.wrapper }) expect(getRenderCount()).toBe(1) // hook mount await waitFor(() => expect(screen.getByTestId('isUninitialized').textContent).toBe('true'), ) await waitFor(() => expect(data).toBeUndefined()) fireEvent.click(screen.getByTestId('fetchButton')) expect(getRenderCount()).toBe(2) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(3) fireEvent.click(screen.getByTestId('updateOptions')) // setState = 1 expect(getRenderCount()).toBe(4) fireEvent.click(screen.getByTestId('fetchButton')) // perform new request = 2 await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(6) interval = 1000 fireEvent.click(screen.getByTestId('updateOptions')) // setState = 1 expect(getRenderCount()).toBe(7) fireEvent.click(screen.getByTestId('fetchButton')) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect(getRenderCount()).toBe(9) expect( actions.filter(api.internalActions.updateSubscriptionOptions.match), ).toHaveLength(1) }) test('useLazyQuery accepts updated args and unsubscribes the original query', async () => { function User() { const [fetchUser, { data: hookData, isFetching, isUninitialized }] = api.endpoints.getUser.useLazyQuery() data = hookData return (
    {String(isUninitialized)}
    {String(isFetching)}
    ) } const { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isUninitialized').textContent).toBe('true'), ) await waitFor(() => expect(data).toBeUndefined()) fireEvent.click(screen.getByTestId('fetchUser1')) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) // Being that there is only the initial query, no unsubscribe should be dispatched expect( actions.filter(api.internalActions.unsubscribeQueryResult.match), ).toHaveLength(0) fireEvent.click(screen.getByTestId('fetchUser2')) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect( actions.filter(api.internalActions.unsubscribeQueryResult.match), ).toHaveLength(1) fireEvent.click(screen.getByTestId('fetchUser1')) expect( actions.filter(api.internalActions.unsubscribeQueryResult.match), ).toHaveLength(2) // we always unsubscribe the original promise and create a new one fireEvent.click(screen.getByTestId('fetchUser1')) expect( actions.filter(api.internalActions.unsubscribeQueryResult.match), ).toHaveLength(3) unmount() // We unsubscribe after the component unmounts expect( actions.filter(api.internalActions.unsubscribeQueryResult.match), ).toHaveLength(4) }) test('useLazyQuery hook callback returns various properties to handle the result', async () => { const user = userEvent.setup() function User() { const [getUser] = api.endpoints.getUser.useLazyQuery() const [{ successMsg, errMsg, isAborted }, setValues] = useState({ successMsg: '', errMsg: '', isAborted: false, }) const handleClick = (abort: boolean) => async () => { const res = getUser(1) // abort the query immediately to force an error if (abort) res.abort() res .unwrap() .then((result) => { setValues({ successMsg: `Successfully fetched user ${result.name}`, errMsg: '', isAborted: false, }) }) .catch((err) => { setValues({ successMsg: '', errMsg: `An error has occurred fetching userId: ${res.arg}`, isAborted: err.name === 'AbortError', }) }) } return (
    {successMsg}
    {errMsg}
    {isAborted ? 'Request was aborted' : ''}
    ) } render(, { wrapper: storeRef.wrapper }) expect(screen.queryByText(/An error has occurred/i)).toBeNull() expect(screen.queryByText(/Successfully fetched user/i)).toBeNull() expect(screen.queryByText('Request was aborted')).toBeNull() fireEvent.click( screen.getByRole('button', { name: 'Fetch User and abort' }), ) await screen.findByText('An error has occurred fetching userId: 1') expect(screen.queryByText(/Successfully fetched user/i)).toBeNull() screen.getByText('Request was aborted') await user.click( screen.getByRole('button', { name: 'Fetch User successfully' }), ) await screen.findByText('Successfully fetched user Timmy') expect(screen.queryByText(/An error has occurred/i)).toBeNull() expect(screen.queryByText('Request was aborted')).toBeNull() }) test('unwrapping the useLazyQuery trigger result does not throw on ConditionError and instead returns the aggregate error', async () => { function User() { const [getUser, { data, error }] = api.endpoints.getUserAndForceError.useLazyQuery() const [unwrappedError, setUnwrappedError] = useState() const handleClick = async () => { const res = getUser(1) try { await res.unwrap() } catch (error) { setUnwrappedError(error) } } return (
    {JSON.stringify(data)}
    {JSON.stringify(error)}
    {JSON.stringify(unwrappedError)}
    ) } render(, { wrapper: storeRef.wrapper }) const fetchButton = screen.getByRole('button', { name: 'Fetch User' }) fireEvent.click(fetchButton) fireEvent.click(fetchButton) // This technically dispatches a ConditionError, but we don't want to see that here. We want the real error to resolve. await waitFor(() => { const errorResult = screen.getByTestId('error')?.textContent const unwrappedErrorResult = screen.getByTestId('unwrappedError')?.textContent if (errorResult && unwrappedErrorResult) { expect(JSON.parse(errorResult)).toMatchObject({ status: 500, data: null, }) expect(JSON.parse(unwrappedErrorResult)).toMatchObject( JSON.parse(errorResult), ) } }) expect(screen.getByTestId('result').textContent).toBe('') }) test('useLazyQuery does not throw on ConditionError and instead returns the aggregate result', async () => { function User() { const [getUser, { data, error }] = api.endpoints.getUser.useLazyQuery() const [unwrappedResult, setUnwrappedResult] = useState< undefined | { name: string } >() const handleClick = async () => { const res = getUser(1) const result = await res.unwrap() setUnwrappedResult(result) } return (
    {JSON.stringify(data)}
    {JSON.stringify(error)}
    {JSON.stringify(unwrappedResult)}
    ) } render(, { wrapper: storeRef.wrapper }) const fetchButton = screen.getByRole('button', { name: 'Fetch User' }) fireEvent.click(fetchButton) fireEvent.click(fetchButton) // This technically dispatches a ConditionError, but we don't want to see that here. We want the real result to resolve and ignore the error. await waitFor(() => { const dataResult = screen.getByTestId('error')?.textContent const unwrappedDataResult = screen.getByTestId('unwrappedResult')?.textContent if (dataResult && unwrappedDataResult) { expect(JSON.parse(dataResult)).toMatchObject({ name: 'Timmy', }) expect(JSON.parse(unwrappedDataResult)).toMatchObject( JSON.parse(dataResult), ) } }) expect(screen.getByTestId('error').textContent).toBe('') }) test('useLazyQuery trigger promise returns the correctly updated data', async () => { const user = userEvent.setup() const LazyUnwrapUseEffect = () => { const [triggerGetIncrementedAmount, { isFetching, isSuccess, data }] = api.endpoints.getIncrementedAmount.useLazyQuery() type AmountData = { amount: number } | undefined const [triggerUpdate] = api.endpoints.triggerUpdatedAmount.useMutation() const [dataFromQuery, setDataFromQuery] = useState(undefined) const [dataFromTrigger, setDataFromTrigger] = useState(undefined) const handleLoad = async () => { try { const res = await triggerGetIncrementedAmount().unwrap() setDataFromTrigger(res) // adding client side state here will cause stale data } catch (error) { console.error(error) } } const handleMutate = async () => { try { await triggerUpdate() // Force the lazy trigger to refetch await handleLoad() } catch (error) { console.error(error) } } useEffect(() => { // Intentionally copy to local state for comparison purposes setDataFromQuery(data) }, [data]) let content: React.ReactNode | null = null if (isFetching) { content =
    Loading
    } else if (isSuccess) { content = (
    useEffect data: {dataFromQuery?.amount ?? 'No query amount'}
    Unwrap data: {dataFromTrigger?.amount ?? 'No trigger amount'}
    ) } return (
    {content}
    ) } render(, { wrapper: storeRef.wrapper }) // Kick off the initial fetch via lazy query trigger await user.click(screen.getByText('Load Data')) // We get back initial data, which should get copied into local state, // and also should come back as valid via the lazy trigger promise await waitFor(() => { expect(screen.getByText('useEffect data: 1')).toBeTruthy() expect(screen.getByText('Unwrap data: 1')).toBeTruthy() }) // If we mutate and then re-run the lazy trigger afterwards... await user.click(screen.getByText('Update Data')) // We should see both sets of data agree (ie, the lazy trigger promise // should not return stale data or be out of sync with the hook). // Prior to PR #4651, this would fail because the trigger never updated properly. await waitFor(() => { expect(screen.getByText('useEffect data: 2')).toBeTruthy() expect(screen.getByText('Unwrap data: 2')).toBeTruthy() }) }) test('`reset` sets state back to original state', async () => { const user = userEvent.setup() function User() { const [getUser, { isSuccess, isUninitialized, reset }, _lastInfo] = api.endpoints.getUser.useLazyQuery() const handleFetchClick = async () => { await getUser(1).unwrap() } return (
    {isUninitialized ? 'isUninitialized' : isSuccess ? 'isSuccess' : 'other'}
    ) } render(, { wrapper: storeRef.wrapper }) await screen.findByText(/isUninitialized/i) expect(countObjectKeys(storeRef.store.getState().api.queries)).toBe(0) await user.click(screen.getByRole('button', { name: 'Fetch User' })) await screen.findByText(/isSuccess/i) expect(countObjectKeys(storeRef.store.getState().api.queries)).toBe(1) await user.click( screen.getByRole('button', { name: 'Reset', }), ) await screen.findByText(/isUninitialized/i) expect(countObjectKeys(storeRef.store.getState().api.queries)).toBe(0) }) }) describe('useMutation', () => { test('useMutation hook sets and unsets the isLoading flag when running', async () => { function User() { const [updateUser, { isLoading }] = api.endpoints.updateUser.useMutation() return (
    {String(isLoading)}
    ) } render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) fireEvent.click(screen.getByText('Update User')) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) }) test('useMutation hook sets data to the resolved response on success', async () => { const result = { name: 'Banana' } function User() { const [updateUser, { data }] = api.endpoints.updateUser.useMutation() return (
    {JSON.stringify(data)}
    ) } render(, { wrapper: storeRef.wrapper }) fireEvent.click(screen.getByText('Update User')) await waitFor(() => expect(screen.getByTestId('result').textContent).toBe( JSON.stringify(result), ), ) }) test('useMutation hook callback returns various properties to handle the result', async () => { const user = userEvent.setup() function User() { const [updateUser] = api.endpoints.updateUser.useMutation() const [successMsg, setSuccessMsg] = useState('') const [errMsg, setErrMsg] = useState('') const [isAborted, setIsAborted] = useState(false) const handleClick = async () => { const res = updateUser({ name: 'Banana' }) // abort the mutation immediately to force an error res.abort() res .unwrap() .then((result) => { setSuccessMsg(`Successfully updated user ${result.name}`) }) .catch((err) => { setErrMsg( `An error has occurred updating user ${res.arg.originalArgs.name}`, ) if (err.name === 'AbortError') { setIsAborted(true) } }) } return (
    {successMsg}
    {errMsg}
    {isAborted ? 'Request was aborted' : ''}
    ) } render(, { wrapper: storeRef.wrapper }) expect(screen.queryByText(/An error has occurred/i)).toBeNull() expect(screen.queryByText(/Successfully updated user/i)).toBeNull() expect(screen.queryByText('Request was aborted')).toBeNull() await user.click( screen.getByRole('button', { name: 'Update User and abort' }), ) await screen.findByText('An error has occurred updating user Banana') expect(screen.queryByText(/Successfully updated user/i)).toBeNull() screen.getByText('Request was aborted') }) test('useMutation return value contains originalArgs', async () => { const { result } = renderHook( () => api.endpoints.updateUser.useMutation(), { wrapper: storeRef.wrapper, }, ) const arg = { name: 'Foo' } const firstRenderResult = result.current expect(firstRenderResult[1].originalArgs).toBe(undefined) await act(async () => { await firstRenderResult[0](arg) }) const secondRenderResult = result.current expect(firstRenderResult[1].originalArgs).toBe(undefined) expect(secondRenderResult[1].originalArgs).toBe(arg) }) test('`reset` sets state back to original state', async () => { const user = userEvent.setup() function User() { const [updateUser, result] = api.endpoints.updateUser.useMutation() return ( <> {result.isUninitialized ? 'isUninitialized' : result.isSuccess ? 'isSuccess' : 'other'} {result.originalArgs?.name} ) } render(, { wrapper: storeRef.wrapper }) await screen.findByText(/isUninitialized/i) expect(screen.queryByText('Yay')).toBeNull() expect(countObjectKeys(storeRef.store.getState().api.mutations)).toBe(0) await user.click(screen.getByRole('button', { name: 'trigger' })) await screen.findByText(/isSuccess/i) expect(screen.queryByText('Yay')).not.toBeNull() expect(countObjectKeys(storeRef.store.getState().api.mutations)).toBe(1) await user.click(screen.getByRole('button', { name: 'reset' })) await screen.findByText(/isUninitialized/i) expect(screen.queryByText('Yay')).toBeNull() expect(countObjectKeys(storeRef.store.getState().api.mutations)).toBe(0) }) }) describe('usePrefetch', () => { test('usePrefetch respects force arg', async () => { const user = userEvent.setup() const { usePrefetch } = api const USER_ID = 4 function User() { const { isFetching } = api.endpoints.getUser.useQuery(USER_ID) const prefetchUser = usePrefetch('getUser', { force: true }) return (
    {String(isFetching)}
    ) } render(, { wrapper: storeRef.wrapper }) // Resolve initial query await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await user.hover(screen.getByTestId('highPriority')) expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', error: undefined, fulfilledTimeStamp: expect.any(Number), isError: false, isLoading: true, isSuccess: false, isUninitialized: false, originalArgs: USER_ID, requestId: expect.any(String), startedTimeStamp: expect.any(Number), status: QueryStatus.pending, }) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', fulfilledTimeStamp: expect.any(Number), isError: false, isLoading: false, isSuccess: true, isUninitialized: false, originalArgs: USER_ID, requestId: expect.any(String), startedTimeStamp: expect.any(Number), status: QueryStatus.fulfilled, }) }) test('usePrefetch does not make an additional request if already in the cache and force=false', async () => { const user = userEvent.setup() const { usePrefetch } = api const USER_ID = 2 function User() { // Load the initial query const { isFetching } = api.endpoints.getUser.useQuery(USER_ID) const prefetchUser = usePrefetch('getUser', { force: false }) return (
    {String(isFetching)}
    ) } render(, { wrapper: storeRef.wrapper }) // Let the initial query resolve await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) // Try to prefetch what we just loaded await user.hover(screen.getByTestId('lowPriority')) expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', fulfilledTimeStamp: expect.any(Number), isError: false, isLoading: false, isSuccess: true, isUninitialized: false, originalArgs: USER_ID, requestId: expect.any(String), startedTimeStamp: expect.any(Number), status: QueryStatus.fulfilled, }) await waitMs() expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', fulfilledTimeStamp: expect.any(Number), isError: false, isLoading: false, isSuccess: true, isUninitialized: false, originalArgs: USER_ID, requestId: expect.any(String), startedTimeStamp: expect.any(Number), status: QueryStatus.fulfilled, }) }) test('usePrefetch respects ifOlderThan when it evaluates to true', async () => { const user = userEvent.setup() const { usePrefetch } = api const USER_ID = 47 function User() { // Load the initial query const { isFetching } = api.endpoints.getUser.useQuery(USER_ID) const prefetchUser = usePrefetch('getUser', { ifOlderThan: 0.2 }) return (
    {String(isFetching)}
    ) } render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) // Wait 400ms, making it respect ifOlderThan await waitMs(400) // This should run the query being that we're past the threshold await user.hover(screen.getByTestId('lowPriority')) expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', fulfilledTimeStamp: expect.any(Number), isError: false, isLoading: true, isSuccess: false, isUninitialized: false, originalArgs: USER_ID, requestId: expect.any(String), startedTimeStamp: expect.any(Number), status: QueryStatus.pending, }) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual({ data: { name: 'Timmy' }, endpointName: 'getUser', fulfilledTimeStamp: expect.any(Number), isError: false, isLoading: false, isSuccess: true, isUninitialized: false, originalArgs: USER_ID, requestId: expect.any(String), startedTimeStamp: expect.any(Number), status: QueryStatus.fulfilled, }) }) test('usePrefetch returns the last success result when ifOlderThan evalutes to false', async () => { const user = userEvent.setup() const { usePrefetch } = api const USER_ID = 2 function User() { // Load the initial query const { isFetching } = api.endpoints.getUser.useQuery(USER_ID) const prefetchUser = usePrefetch('getUser', { ifOlderThan: 10 }) return (
    {String(isFetching)}
    ) } render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitMs() // Get a snapshot of the last result const latestQueryData = api.endpoints.getUser.select(USER_ID)( storeRef.store.getState() as any, ) await user.hover(screen.getByTestId('lowPriority')) // Serve up the result from the cache being that the condition wasn't met expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState() as any), ).toEqual(latestQueryData) }) test('usePrefetch executes a query even if conditions fail when the cache is empty', async () => { const user = userEvent.setup() const { usePrefetch } = api const USER_ID = 2 function User() { const prefetchUser = usePrefetch('getUser', { ifOlderThan: 10 }) return (
    ) } render(, { wrapper: storeRef.wrapper }) await user.hover(screen.getByTestId('lowPriority')) expect( api.endpoints.getUser.select(USER_ID)(storeRef.store.getState()), ).toEqual({ endpointName: 'getUser', isError: false, isLoading: true, isSuccess: false, isUninitialized: false, originalArgs: USER_ID, requestId: expect.any(String), startedTimeStamp: expect.any(Number), status: 'pending', }) }) }) describe('useQuery and useMutation invalidation behavior', () => { const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), tagTypes: ['User'], endpoints: (build) => ({ checkSession: build.query({ query: () => '/me', providesTags: ['User'], }), login: build.mutation({ query: () => ({ url: '/login', method: 'POST' }), invalidatesTags: ['User'], }), }), }) const storeRef = setupApiStore(api, { ...actionsReducer }) test('initially failed useQueries that provide an tag will refetch after a mutation invalidates it', async () => { const checkSessionData = { name: 'matt' } server.use( http.get( 'https://example.com/me', () => { return HttpResponse.json(null, { status: 500 }) }, { once: true }, ), http.get('https://example.com/me', () => { return HttpResponse.json(checkSessionData) }), http.post('https://example.com/login', () => { return HttpResponse.json(null, { status: 200 }) }), ) let data, isLoading, isError function User() { ;({ data, isError, isLoading } = api.endpoints.checkSession.useQuery()) const [login, { isLoading: loginLoading }] = api.endpoints.login.useMutation() return (
    {String(isLoading)}
    {String(isError)}
    {JSON.stringify(data)}
    {String(loginLoading)}
    ) } render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('isError').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('user').textContent).toBe(''), ) fireEvent.click(screen.getByRole('button', { name: /Login/i })) await waitFor(() => expect(screen.getByTestId('loginLoading').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('loginLoading').textContent).toBe('false'), ) // login mutation will cause the original errored out query to refire, clearing the error and setting the user await waitFor(() => expect(screen.getByTestId('isError').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('user').textContent).toBe( JSON.stringify(checkSessionData), ), ) const { checkSession, login } = api.endpoints expect(storeRef.store.getState().actions).toMatchSequence( api.internalActions.middlewareRegistered.match, checkSession.matchPending, checkSession.matchRejected, login.matchPending, login.matchFulfilled, checkSession.matchPending, checkSession.matchFulfilled, ) }) }) }) describe('hooks with createApi defaults set', () => { const defaultApi = createApi({ baseQuery: async (arg: any) => { await waitMs() if ('amount' in arg?.body) { amount += 1 } return { data: arg?.body ? { ...arg.body, ...(amount ? { amount } : {}) } : undefined, } }, endpoints: (build) => ({ getIncrementedAmount: build.query({ query: () => ({ url: '', body: { amount, }, }), }), }), refetchOnMountOrArgChange: true, }) const storeRef = setupApiStore(defaultApi) test('useQuery hook respects refetchOnMountOrArgChange: true when set in createApi options', async () => { let data, isLoading, isFetching function User() { ;({ data, isLoading } = defaultApi.endpoints.getIncrementedAmount.useQuery()) return (
    {String(isLoading)}
    {String(data?.amount)}
    ) } const { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('1'), ) unmount() function OtherUser() { ;({ data, isFetching } = defaultApi.endpoints.getIncrementedAmount.useQuery(undefined, { refetchOnMountOrArgChange: true, })) return (
    {String(isFetching)}
    {String(data?.amount)}
    ) } render(, { wrapper: storeRef.wrapper }) // Let's make sure we actually fetch, and we increment await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('2'), ) }) test('useQuery hook overrides default refetchOnMountOrArgChange: false that was set by createApi', async () => { let data, isLoading, isFetching function User() { ;({ data, isLoading } = defaultApi.endpoints.getIncrementedAmount.useQuery()) return (
    {String(isLoading)}
    {String(data?.amount)}
    ) } let { unmount } = render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('true'), ) await waitFor(() => expect(screen.getByTestId('isLoading').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('1'), ) unmount() function OtherUser() { ;({ data, isFetching } = defaultApi.endpoints.getIncrementedAmount.useQuery(undefined, { refetchOnMountOrArgChange: false, })) return (
    {String(isFetching)}
    {String(data?.amount)}
    ) } render(, { wrapper: storeRef.wrapper }) await waitFor(() => expect(screen.getByTestId('isFetching').textContent).toBe('false'), ) await waitFor(() => expect(screen.getByTestId('amount').textContent).toBe('1'), ) }) describe('selectFromResult (query) behaviors', () => { let startingId = 3 const initialPosts = [ { id: 1, name: 'A sample post', fetched_at: new Date().toUTCString() }, { id: 2, name: 'A post about rtk-query', fetched_at: new Date().toUTCString(), }, ] let posts = [] as typeof initialPosts beforeEach(() => { startingId = 3 posts = [...initialPosts] const handlers = [ http.get('https://example.com/posts', () => { return HttpResponse.json(posts) }), http.put<{ id: string }, Partial>( 'https://example.com/post/:id', async ({ request, params }) => { const body = await request.json() const id = Number(params.id) const idx = posts.findIndex((post) => post.id === id) const newPosts = posts.map((post, index) => index !== idx ? post : { ...body, id, name: body?.name || post.name, fetched_at: new Date().toUTCString(), }, ) posts = [...newPosts] return HttpResponse.json(posts) }, ), http.post>( 'https://example.com/post', async ({ request }) => { const body = await request.json() const post = body startingId += 1 posts.concat({ ...post, fetched_at: new Date().toISOString(), id: startingId, }) return HttpResponse.json(posts) }, ), ] server.use(...handlers) }) interface Post { id: number name: string fetched_at: string } type PostsResponse = Post[] const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/' }), tagTypes: ['Posts'], endpoints: (build) => ({ getPosts: build.query({ query: () => ({ url: 'posts' }), providesTags: (result) => result ? result.map(({ id }) => ({ type: 'Posts', id })) : [], }), updatePost: build.mutation>({ query: ({ id, ...body }) => ({ url: `post/${id}`, method: 'PUT', body, }), invalidatesTags: (result, error, { id }) => [{ type: 'Posts', id }], }), addPost: build.mutation>({ query: (body) => ({ url: `post`, method: 'POST', body, }), invalidatesTags: ['Posts'], }), }), }) const counterSlice = createSlice({ name: 'counter', initialState: { count: 0 }, reducers: { increment(state) { state.count++ }, }, }) const storeRef = setupApiStore(api, { counter: counterSlice.reducer, }) test('useQueryState serves a deeply memoized value and does not rerender unnecessarily', async () => { function Posts() { const { data: posts } = api.endpoints.getPosts.useQuery() const [addPost] = api.endpoints.addPost.useMutation() return (
    ) } function SelectedPost() { const { post } = api.endpoints.getPosts.useQueryState(undefined, { selectFromResult: ({ data }) => ({ post: data?.find((post) => post.id === 1), }), }) getRenderCount = useRenderCounter() /** * Notes on the renderCount behavior * * We initialize at 0, and the first render will bump that 1 while post is `undefined`. * Once the request resolves, it will be at 2. What we're looking for is to make sure that * any requests that don't directly change the value of the selected item will have no impact * on rendering. */ return
    } render(
    , { wrapper: storeRef.wrapper }, ) expect(getRenderCount()).toBe(1) const addBtn = screen.getByTestId('addPost') await waitFor(() => expect(getRenderCount()).toBe(2)) fireEvent.click(addBtn) await waitFor(() => expect(getRenderCount()).toBe(2)) // We fire off a few requests that would typically cause a rerender as JSON.parse() on a request would always be a new object. fireEvent.click(addBtn) fireEvent.click(addBtn) await waitFor(() => expect(getRenderCount()).toBe(2)) // Being that it didn't rerender, we can be assured that the behavior is correct }) /** * This test shows that even though a user can select a specific post, the fetching/loading flags * will still cause rerenders for the query. This should show that if you're using selectFromResult, * the 'performance' value comes with selecting _only_ the data. */ test('useQuery with selectFromResult with all flags destructured rerenders like the default useQuery behavior', async () => { function Posts() { const { data: posts } = api.endpoints.getPosts.useQuery() const [addPost] = api.endpoints.addPost.useMutation() getRenderCount = useRenderCounter() return (
    ) } function SelectedPost() { getRenderCount = useRenderCounter() const { post } = api.endpoints.getPosts.useQuery(undefined, { selectFromResult: ({ data, isUninitialized, isLoading, isFetching, isSuccess, isError, }) => ({ post: data?.find((post) => post.id === 1), isUninitialized, isLoading, isFetching, isSuccess, isError, }), }) return
    } render(
    , { wrapper: storeRef.wrapper }, ) expect(getRenderCount()).toBe(2) const addBtn = screen.getByTestId('addPost') await waitFor(() => expect(getRenderCount()).toBe(3)) fireEvent.click(addBtn) await waitFor(() => expect(getRenderCount()).toBe(5)) fireEvent.click(addBtn) fireEvent.click(addBtn) await waitFor(() => expect(getRenderCount()).toBe(7)) }) test('useQuery with selectFromResult option serves a deeply memoized value and does not rerender unnecessarily', async () => { function Posts() { const { data: posts } = api.endpoints.getPosts.useQuery() const [addPost] = api.endpoints.addPost.useMutation() return (
    ) } function SelectedPost() { getRenderCount = useRenderCounter() const { post } = api.endpoints.getPosts.useQuery(undefined, { selectFromResult: ({ data }) => ({ post: data?.find((post) => post.id === 1), }), }) return
    } render(
    , { wrapper: storeRef.wrapper }, ) expect(getRenderCount()).toBe(1) const addBtn = screen.getByTestId('addPost') await waitFor(() => expect(getRenderCount()).toBe(2)) fireEvent.click(addBtn) await waitFor(() => expect(getRenderCount()).toBe(2)) fireEvent.click(addBtn) fireEvent.click(addBtn) await waitFor(() => expect(getRenderCount()).toBe(2)) }) test('useQuery with selectFromResult option serves a deeply memoized value, then ONLY updates when the underlying data changes', async () => { let expectablePost: Post | undefined function Posts() { const { data: posts } = api.endpoints.getPosts.useQuery() const [addPost] = api.endpoints.addPost.useMutation() const [updatePost] = api.endpoints.updatePost.useMutation() return (
    ) } function SelectedPost() { const { post } = api.endpoints.getPosts.useQuery(undefined, { selectFromResult: ({ data }) => ({ post: data?.find((post) => post.id === 1), }), }) getRenderCount = useRenderCounter() useEffect(() => { expectablePost = post }, [post]) return (
    {post?.name}
    ) } render(
    , { wrapper: storeRef.wrapper }, ) expect(getRenderCount()).toBe(1) const addBtn = screen.getByTestId('addPost') const updateBtn = screen.getByTestId('updatePost') fireEvent.click(addBtn) await waitFor(() => expect(getRenderCount()).toBe(2)) fireEvent.click(addBtn) fireEvent.click(addBtn) await waitFor(() => expect(getRenderCount()).toBe(2)) fireEvent.click(updateBtn) await waitFor(() => expect(getRenderCount()).toBe(3)) expect(expectablePost?.name).toBe('supercoooll!') fireEvent.click(addBtn) await waitFor(() => expect(getRenderCount()).toBe(3)) }) test('useQuery with selectFromResult option does not update when unrelated data in the store changes', async () => { function Posts() { const { posts } = api.endpoints.getPosts.useQuery(undefined, { selectFromResult: ({ data }) => ({ // Intentionally use an unstable reference to force a rerender posts: data?.filter((post) => post.name.includes('post')), }), }) getRenderCount = useRenderCounter() return (
    {posts?.map((post) =>
    {post.name}
    )}
    ) } function CounterButton() { return (
    storeRef.store.dispatch(counterSlice.actions.increment()) } > Increment Count
    ) } render(
    , { wrapper: storeRef.wrapper }, ) await waitFor(() => expect(getRenderCount()).toBe(2)) const incrementBtn = screen.getByTestId('incrementButton') fireEvent.click(incrementBtn) expect(getRenderCount()).toBe(2) }) test('useQuery with selectFromResult option has a type error if the result is not an object', async () => { function SelectedPost() { const res2 = api.endpoints.getPosts.useQuery(undefined, { // selectFromResult must always return an object selectFromResult: ({ data }) => ({ size: data?.length ?? 0 }), }) return (
    {res2.size}
    ) } render(
    , { wrapper: storeRef.wrapper }, ) expect(screen.getByTestId('size2').textContent).toBe('0') }) }) describe('selectFromResult (mutation) behavior', () => { const api = createApi({ baseQuery: async (arg: any) => { await waitMs() if ('amount' in arg?.body) { amount += 1 } return { data: arg?.body ? { ...arg.body, ...(amount ? { amount } : {}) } : undefined, } }, endpoints: (build) => ({ increment: build.mutation<{ amount: number }, number>({ query: (amount) => ({ url: '', method: 'POST', body: { amount, }, }), }), }), }) const storeRef = setupApiStore(api, { ...actionsReducer, }) it('causes no more than one rerender when using selectFromResult with an empty object', async () => { function Counter() { const [increment] = api.endpoints.increment.useMutation({ selectFromResult: () => ({}), }) getRenderCount = useRenderCounter() return (
    ) } render(, { wrapper: storeRef.wrapper }) expect(getRenderCount()).toBe(1) fireEvent.click(screen.getByTestId('incrementButton')) await waitMs(200) // give our baseQuery a chance to return expect(getRenderCount()).toBe(2) fireEvent.click(screen.getByTestId('incrementButton')) await waitMs(200) expect(getRenderCount()).toBe(3) const { increment } = api.endpoints expect(storeRef.store.getState().actions).toMatchSequence( api.internalActions.middlewareRegistered.match, increment.matchPending, increment.matchFulfilled, increment.matchPending, api.internalActions.removeMutationResult.match, increment.matchFulfilled, ) }) it('causes rerenders when only selected data changes', async () => { function Counter() { const [increment, { data }] = api.endpoints.increment.useMutation({ selectFromResult: ({ data }) => ({ data }), }) getRenderCount = useRenderCounter() return (
    {JSON.stringify(data)}
    ) } render(, { wrapper: storeRef.wrapper }) expect(getRenderCount()).toBe(1) fireEvent.click(screen.getByTestId('incrementButton')) await waitFor(() => expect(screen.getByTestId('data').textContent).toBe( JSON.stringify({ amount: 1 }), ), ) expect(getRenderCount()).toBe(3) fireEvent.click(screen.getByTestId('incrementButton')) await waitFor(() => expect(screen.getByTestId('data').textContent).toBe( JSON.stringify({ amount: 2 }), ), ) expect(getRenderCount()).toBe(5) }) it('causes the expected # of rerenders when NOT using selectFromResult', async () => { function Counter() { const [increment, data] = api.endpoints.increment.useMutation() getRenderCount = useRenderCounter() return (
    {String(data.status)}
    ) } render(, { wrapper: storeRef.wrapper }) expect(getRenderCount()).toBe(1) // mount, uninitialized status in substate fireEvent.click(screen.getByTestId('incrementButton')) expect(getRenderCount()).toBe(2) // will be pending, isLoading: true, await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('pending'), ) await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('fulfilled'), ) expect(getRenderCount()).toBe(3) fireEvent.click(screen.getByTestId('incrementButton')) await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('pending'), ) await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('fulfilled'), ) expect(getRenderCount()).toBe(5) }) it('useMutation with selectFromResult option has a type error if the result is not an object', async () => { function Counter() { const [increment] = api.endpoints.increment.useMutation({ // selectFromResult must always return an object // @ts-expect-error selectFromResult: () => 42, }) return (
    ) } render(, { wrapper: storeRef.wrapper }) }) }) }) describe('skip behavior', () => { const uninitialized = { status: QueryStatus.uninitialized, refetch: expect.any(Function), data: undefined, isError: false, isFetching: false, isLoading: false, isSuccess: false, isUninitialized: true, } test('normal skip', async () => { const { result, rerender } = renderHook( ([arg, options]: Parameters) => api.endpoints.getUser.useQuery(arg, options), { wrapper: storeRef.wrapper, initialProps: [1, { skip: true }], }, ) expect(result.current).toEqual(uninitialized) await waitMs(1) expect(getSubscriptionCount('getUser(1)')).toBe(0) rerender([1]) await act(async () => { await waitForFakeTimer(150) }) expect(result.current).toMatchObject({ status: QueryStatus.fulfilled }) await waitMs(1) expect(getSubscriptionCount('getUser(1)')).toBe(1) rerender([1, { skip: true }]) expect(result.current).toEqual({ ...uninitialized, isSuccess: true, currentData: undefined, data: { name: 'Timmy' }, }) await waitMs(1) expect(getSubscriptionCount('getUser(1)')).toBe(0) }) test('skipToken', async () => { const { result, rerender } = renderHook( ([arg, options]: Parameters) => api.endpoints.getUser.useQuery(arg, options), { wrapper: storeRef.wrapper, initialProps: [skipToken], }, ) expect(result.current).toEqual(uninitialized) await waitMs(1) expect(getSubscriptionCount('getUser(1)')).toBe(0) // also no subscription on `getUser(skipToken)` or similar: expect(getSubscriptions()).toEqual({}) rerender([1]) await act(async () => { await waitForFakeTimer(150) }) expect(result.current).toMatchObject({ status: QueryStatus.fulfilled }) await waitMs(1) expect(getSubscriptionCount('getUser(1)')).toBe(1) expect(getSubscriptions()).not.toEqual({}) rerender([skipToken]) expect(result.current).toEqual({ ...uninitialized, isSuccess: true, currentData: undefined, data: { name: 'Timmy' }, }) await waitMs(1) expect(getSubscriptionCount('getUser(1)')).toBe(0) }) test('skipToken does not break serializeQueryArgs', async () => { const { result, rerender } = renderHook( ([arg, options]: Parameters< typeof api.endpoints.queryWithDeepArg.useQuery >) => api.endpoints.queryWithDeepArg.useQuery(arg, options), { wrapper: storeRef.wrapper, initialProps: [skipToken], }, ) expect(result.current).toEqual(uninitialized) await waitMs(1) expect(getSubscriptionCount('nestedValue')).toBe(0) // also no subscription on `getUser(skipToken)` or similar: expect(getSubscriptions()).toEqual({}) rerender([{ param: { nested: 'nestedValue' } }]) await act(async () => { await waitForFakeTimer(150) }) expect(result.current).toMatchObject({ status: QueryStatus.fulfilled }) await waitMs(1) expect(getSubscriptionCount('nestedValue')).toBe(1) expect(getSubscriptions()).not.toEqual({}) rerender([skipToken]) expect(result.current).toEqual({ ...uninitialized, isSuccess: true, currentData: undefined, data: {}, }) await waitMs(1) expect(getSubscriptionCount('nestedValue')).toBe(0) }) test('skipping a previously fetched query retains the existing value as `data`, but clears `currentData`', async () => { const { result, rerender } = renderHook( ([arg, options]: Parameters) => api.endpoints.getUser.useQuery(arg, options), { wrapper: storeRef.wrapper, initialProps: [1], }, ) await act(async () => { await waitForFakeTimer(150) }) // Normal fulfilled result, with both `data` and `currentData` expect(result.current).toMatchObject({ status: QueryStatus.fulfilled, isSuccess: true, data: { name: 'Timmy' }, currentData: { name: 'Timmy' }, }) rerender([1, { skip: true }]) // After skipping, the query is "uninitialized", but still retains the last fetched `data` // even though it's skipped. `currentData` is undefined, since that matches the current arg. expect(result.current).toMatchObject({ status: QueryStatus.uninitialized, isSuccess: true, data: { name: 'Timmy' }, currentData: undefined, }) }) })