import { fireEvent, waitFor } from '@testing-library/react' import * as React from 'react' import { ErrorBoundary } from 'react-error-boundary' import * as QueriesObserverModule from '../../../query-core/src/queriesObserver' import { createQueryClient, expectType, expectTypeNotAny, queryKey, renderWithClient, sleep, } from './utils' import type { QueryClient, QueryFunction, QueryKey, QueryObserverResult, UseQueryOptions, UseQueryResult, } from '..' import { QueriesObserver, QueryCache, useQueries } from '..' import type { QueryFunctionContext } from '@tanstack/query-core' describe('useQueries', () => { const queryCache = new QueryCache() const queryClient = createQueryClient({ queryCache }) it('should return the correct states', async () => { const key1 = queryKey() const key2 = queryKey() const results: UseQueryResult[][] = [] function Page() { const result = useQueries({ queries: [ { queryKey: key1, queryFn: async () => { await sleep(10) return 1 }, }, { queryKey: key2, queryFn: async () => { await sleep(200) return 2 }, }, ], }) results.push(result) return (
data1: {String(result[0].data ?? 'null')}, data2:{' '} {String(result[1].data ?? 'null')}
) } const rendered = renderWithClient(queryClient, ) await waitFor(() => rendered.getByText('data1: 1, data2: 2')) expect(results.length).toBe(3) expect(results[0]).toMatchObject([{ data: undefined }, { data: undefined }]) expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }]) expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }]) }) it('should keep previous data if amount of queries is the same', async () => { const key1 = queryKey() const key2 = queryKey() const states: UseQueryResult[][] = [] function Page() { const [count, setCount] = React.useState(1) const result = useQueries({ queries: [ { queryKey: [key1, count], keepPreviousData: true, queryFn: async () => { await sleep(10) return count * 2 }, }, { queryKey: [key2, count], keepPreviousData: true, queryFn: async () => { await sleep(35) return count * 5 }, }, ], }) states.push(result) const isFetching = result.some((r) => r.isFetching) return (
data1: {String(result[0].data ?? 'null')}, data2:{' '} {String(result[1].data ?? 'null')}
isFetching: {String(isFetching)}
) } const rendered = renderWithClient(queryClient, ) await waitFor(() => rendered.getByText('data1: 2, data2: 5')) fireEvent.click(rendered.getByRole('button', { name: /inc/i })) await waitFor(() => rendered.getByText('data1: 4, data2: 10')) await waitFor(() => rendered.getByText('isFetching: false')) expect(states[states.length - 1]).toMatchObject([ { status: 'success', data: 4, isPreviousData: false, isFetching: false }, { status: 'success', data: 10, isPreviousData: false, isFetching: false }, ]) }) it('should keep previous data for variable amounts of useQueries', async () => { const key = queryKey() const states: UseQueryResult[][] = [] function Page() { const [count, setCount] = React.useState(2) const result = useQueries({ queries: Array.from({ length: count }, (_, i) => ({ queryKey: [key, count, i + 1], keepPreviousData: true, queryFn: async () => { await sleep(35 * (i + 1)) return (i + 1) * count * 2 }, })), }) states.push(result) const isFetching = result.some((r) => r.isFetching) return (
data: {result.map((it) => it.data).join(',')}
isFetching: {String(isFetching)}
) } const rendered = renderWithClient(queryClient, ) await waitFor(() => rendered.getByText('data: 4,8')) fireEvent.click(rendered.getByRole('button', { name: /inc/i })) await waitFor(() => rendered.getByText('data: 6,12,18')) await waitFor(() => rendered.getByText('isFetching: false')) expect(states[states.length - 1]).toMatchObject([ { status: 'success', data: 6, isPreviousData: false, isFetching: false }, { status: 'success', data: 12, isPreviousData: false, isFetching: false }, { status: 'success', data: 18, isPreviousData: false, isFetching: false }, ]) }) it('should keep previous data when switching between queries', async () => { const key = queryKey() const states: UseQueryResult[][] = [] function Page() { const [series1, setSeries1] = React.useState(1) const [series2, setSeries2] = React.useState(2) const ids = [series1, series2] const result = useQueries({ queries: ids.map((id) => { return { queryKey: [key, id], queryFn: async () => { await sleep(5) return id * 5 }, keepPreviousData: true, } }), }) states.push(result) const isFetching = result.some((r) => r.isFetching) return (
data1: {String(result[0]?.data ?? 'null')}, data2:{' '} {String(result[1]?.data ?? 'null')}
isFetching: {String(isFetching)}
) } const rendered = renderWithClient(queryClient, ) await waitFor(() => rendered.getByText('data1: 5, data2: 10')) fireEvent.click(rendered.getByRole('button', { name: /setSeries2/i })) await waitFor(() => rendered.getByText('data1: 5, data2: 15')) fireEvent.click(rendered.getByRole('button', { name: /setSeries1/i })) await waitFor(() => rendered.getByText('data1: 10, data2: 15')) await waitFor(() => rendered.getByText('isFetching: false')) expect(states[states.length - 1]).toMatchObject([ { status: 'success', data: 10, isPreviousData: false, isFetching: false }, { status: 'success', data: 15, isPreviousData: false, isFetching: false }, ]) }) it('should not go to infinite render loop with previous data when toggling queries', async () => { const key = queryKey() const states: UseQueryResult[][] = [] function Page() { const [enableId1, setEnableId1] = React.useState(true) const ids = enableId1 ? [1, 2] : [2] const result = useQueries({ queries: ids.map((id) => { return { queryKey: [key, id], queryFn: async () => { await sleep(5) return id * 5 }, keepPreviousData: true, } }), }) states.push(result) const isFetching = result.some((r) => r.isFetching) return (
data1: {String(result[0]?.data ?? 'null')}, data2:{' '} {String(result[1]?.data ?? 'null')}
isFetching: {String(isFetching)}
) } const rendered = renderWithClient(queryClient, ) await waitFor(() => rendered.getByText('data1: 5, data2: 10')) fireEvent.click(rendered.getByRole('button', { name: /set1Disabled/i })) await waitFor(() => rendered.getByText('data1: 10, data2: null')) await waitFor(() => rendered.getByText('isFetching: false')) fireEvent.click(rendered.getByRole('button', { name: /set2Enabled/i })) await waitFor(() => rendered.getByText('data1: 5, data2: 10')) await waitFor(() => rendered.getByText('isFetching: false')) await waitFor(() => expect(states.length).toBe(6)) expect(states[0]).toMatchObject([ { status: 'loading', data: undefined, isPreviousData: false, isFetching: true, }, { status: 'loading', data: undefined, isPreviousData: false, isFetching: true, }, ]) expect(states[1]).toMatchObject([ { status: 'success', data: 5, isPreviousData: false, isFetching: false }, { status: 'success', data: 10, isPreviousData: false, isFetching: false }, ]) expect(states[2]).toMatchObject([ { status: 'success', data: 10, isPreviousData: false, isFetching: false }, ]) expect(states[3]).toMatchObject([ { status: 'success', data: 5, isPreviousData: false, isFetching: true }, { status: 'success', data: 10, isPreviousData: false, isFetching: false }, ]) expect(states[4]).toMatchObject([ { status: 'success', data: 5, isPreviousData: false, isFetching: true }, { status: 'success', data: 10, isPreviousData: false, isFetching: false }, ]) expect(states[5]).toMatchObject([ { status: 'success', data: 5, isPreviousData: false, isFetching: false }, { status: 'success', data: 10, isPreviousData: false, isFetching: false }, ]) }) it('handles type parameter - tuple of tuples', async () => { const key1 = queryKey() const key2 = queryKey() const key3 = queryKey() // @ts-expect-error (Page component is not rendered) // eslint-disable-next-line function Page() { const result1 = useQueries<[[number], [string], [string[], boolean]]>({ queries: [ { queryKey: key1, queryFn: () => 1, }, { queryKey: key2, queryFn: () => 'string', }, { queryKey: key3, queryFn: () => ['string[]'], }, ], }) expectType>(result1[0]) expectType>(result1[1]) expectType>(result1[2]) expectType(result1[0].data) expectType(result1[1].data) expectType(result1[2].data) expectType(result1[2].error) // TData (3rd element) takes precedence over TQueryFnData (1st element) const result2 = useQueries< [[string, unknown, string], [string, unknown, number]] >({ queries: [ { queryKey: key1, queryFn: () => 'string', select: (a) => { expectType(a) expectTypeNotAny(a) return a.toLowerCase() }, }, { queryKey: key2, queryFn: () => 'string', select: (a) => { expectType(a) expectTypeNotAny(a) return parseInt(a) }, }, ], }) expectType>(result2[0]) expectType>(result2[1]) expectType(result2[0].data) expectType(result2[1].data) // types should be enforced useQueries<[[string, unknown, string], [string, boolean, number]]>({ queries: [ { queryKey: key1, queryFn: () => 'string', select: (a) => { expectType(a) expectTypeNotAny(a) return a.toLowerCase() }, onSuccess: (a) => { expectType(a) expectTypeNotAny(a) }, placeholderData: 'string', // @ts-expect-error (initialData: string) initialData: 123, }, { queryKey: key2, queryFn: () => 'string', select: (a) => { expectType(a) expectTypeNotAny(a) return parseInt(a) }, onSuccess: (a) => { expectType(a) expectTypeNotAny(a) }, onError: (e) => { expectType(e) expectTypeNotAny(e) }, placeholderData: 'string', // @ts-expect-error (initialData: string) initialData: 123, }, ], }) // field names should be enforced useQueries<[[string]]>({ queries: [ { queryKey: key1, queryFn: () => 'string', // @ts-expect-error (invalidField) someInvalidField: [], }, ], }) } }) it('handles type parameter - tuple of objects', async () => { const key1 = queryKey() const key2 = queryKey() const key3 = queryKey() // @ts-expect-error (Page component is not rendered) // eslint-disable-next-line function Page() { const result1 = useQueries< [ { queryFnData: number }, { queryFnData: string }, { queryFnData: string[]; error: boolean }, ] >({ queries: [ { queryKey: key1, queryFn: () => 1, }, { queryKey: key2, queryFn: () => 'string', }, { queryKey: key3, queryFn: () => ['string[]'], }, ], }) expectType>(result1[0]) expectType>(result1[1]) expectType>(result1[2]) expectType(result1[0].data) expectType(result1[1].data) expectType(result1[2].data) expectType(result1[2].error) // TData (data prop) takes precedence over TQueryFnData (queryFnData prop) const result2 = useQueries< [ { queryFnData: string; data: string }, { queryFnData: string; data: number }, ] >({ queries: [ { queryKey: key1, queryFn: () => 'string', select: (a) => { expectType(a) expectTypeNotAny(a) return a.toLowerCase() }, }, { queryKey: key2, queryFn: () => 'string', select: (a) => { expectType(a) expectTypeNotAny(a) return parseInt(a) }, }, ], }) expectType>(result2[0]) expectType>(result2[1]) expectType(result2[0].data) expectType(result2[1].data) // can pass only TData (data prop) although TQueryFnData will be left unknown const result3 = useQueries<[{ data: string }, { data: number }]>({ queries: [ { queryKey: key1, queryFn: () => 'string', select: (a) => { expectType(a) expectTypeNotAny(a) return a as string }, }, { queryKey: key2, queryFn: () => 'string', select: (a) => { expectType(a) expectTypeNotAny(a) return a as number }, }, ], }) expectType>(result3[0]) expectType>(result3[1]) expectType(result3[0].data) expectType(result3[1].data) // types should be enforced useQueries< [ { queryFnData: string; data: string }, { queryFnData: string; data: number; error: boolean }, ] >({ queries: [ { queryKey: key1, queryFn: () => 'string', select: (a) => { expectType(a) expectTypeNotAny(a) return a.toLowerCase() }, onSuccess: (a) => { expectType(a) expectTypeNotAny(a) }, placeholderData: 'string', // @ts-expect-error (initialData: string) initialData: 123, }, { queryKey: key2, queryFn: () => 'string', select: (a) => { expectType(a) expectTypeNotAny(a) return parseInt(a) }, onSuccess: (a) => { expectType(a) expectTypeNotAny(a) }, onError: (e) => { expectType(e) expectTypeNotAny(e) }, placeholderData: 'string', // @ts-expect-error (initialData: string) initialData: 123, }, ], }) // field names should be enforced useQueries<[{ queryFnData: string }]>({ queries: [ { queryKey: key1, queryFn: () => 'string', // @ts-expect-error (invalidField) someInvalidField: [], }, ], }) } }) it('handles array literal without type parameter to infer result type', async () => { const key1 = queryKey() const key2 = queryKey() const key3 = queryKey() const key4 = queryKey() // @ts-expect-error (Page component is not rendered) // eslint-disable-next-line function Page() { // Array.map preserves TQueryFnData const result1 = useQueries({ queries: Array(50).map((_, i) => ({ queryKey: ['key', i] as const, queryFn: () => i + 10, })), }) expectType[]>(result1) expectType(result1[0]?.data) // Array.map preserves TData const result2 = useQueries({ queries: Array(50).map((_, i) => ({ queryKey: ['key', i] as const, queryFn: () => i + 10, select: (data: number) => data.toString(), })), }) expectType[]>(result2) const result3 = useQueries({ queries: [ { queryKey: key1, queryFn: () => 1, }, { queryKey: key2, queryFn: () => 'string', }, { queryKey: key3, queryFn: () => ['string[]'], select: () => 123, }, ], }) expectType>(result3[0]) expectType>(result3[1]) expectType>(result3[2]) expectType(result3[0].data) expectType(result3[1].data) // select takes precedence over queryFn expectType(result3[2].data) // initialData/placeholderData are enforced useQueries({ queries: [ { queryKey: key1, queryFn: () => 'string', placeholderData: 'string', // @ts-expect-error (initialData: string) initialData: 123, }, { queryKey: key2, queryFn: () => 123, // @ts-expect-error (placeholderData: number) placeholderData: 'string', initialData: 123, }, ], }) // select / onSuccess / onSettled params are "indirectly" enforced useQueries({ queries: [ // unfortunately TS will not suggest the type for you { queryKey: key1, queryFn: () => 'string', // @ts-expect-error (noImplicitAny) onSuccess: (a) => null, // @ts-expect-error (noImplicitAny) onSettled: (a) => null, }, // however you can add a type to the callback { queryKey: key2, queryFn: () => 'string', onSuccess: (a: string) => { expectType(a) expectTypeNotAny(a) }, onSettled: (a: string | undefined) => { expectType(a) expectTypeNotAny(a) }, }, // the type you do pass is enforced { queryKey: key3, queryFn: () => 'string', // @ts-expect-error (only accepts string) onSuccess: (a: number) => null, }, { queryKey: key4, queryFn: () => 'string', select: (a: string) => parseInt(a), // @ts-expect-error (select is defined => only accepts number) onSuccess: (a: string) => null, onSettled: (a: number | undefined) => { expectType(a) expectTypeNotAny(a) }, }, ], }) // callbacks are also indirectly enforced with Array.map useQueries({ // @ts-expect-error (onSuccess only accepts string) queries: Array(50).map((_, i) => ({ queryKey: ['key', i] as const, queryFn: () => i + 10, select: (data: number) => data.toString(), onSuccess: (_data: number) => null, })), }) useQueries({ queries: Array(50).map((_, i) => ({ queryKey: ['key', i] as const, queryFn: () => i + 10, select: (data: number) => data.toString(), onSuccess: (_data: string) => null, })), }) // results inference works when all the handlers are defined const result4 = useQueries({ queries: [ { queryKey: key1, queryFn: () => 'string', // @ts-expect-error (noImplicitAny) onSuccess: (a) => null, // @ts-expect-error (noImplicitAny) onSettled: (a) => null, }, { queryKey: key2, queryFn: () => 'string', onSuccess: (a: string) => { expectType(a) expectTypeNotAny(a) }, onSettled: (a: string | undefined) => { expectType(a) expectTypeNotAny(a) }, }, { queryKey: key4, queryFn: () => 'string', select: (a: string) => parseInt(a), onSuccess: (_a: number) => null, onSettled: (a: number | undefined) => { expectType(a) expectTypeNotAny(a) }, }, ], }) expectType>(result4[0]) expectType>(result4[1]) expectType>(result4[2]) // handles when queryFn returns a Promise const result5 = useQueries({ queries: [ { queryKey: key1, queryFn: () => Promise.resolve('string'), onSuccess: (a: string) => { expectType(a) expectTypeNotAny(a) }, // @ts-expect-error (refuses to accept a Promise) onSettled: (a: Promise) => null, }, ], }) expectType>(result5[0]) // Array as const does not throw error const result6 = useQueries({ queries: [ { queryKey: ['key1'], queryFn: () => 'string', }, { queryKey: ['key1'], queryFn: () => 123, }, ], } as const) expectType>(result6[0]) expectType>(result6[1]) // field names should be enforced - array literal useQueries({ queries: [ { queryKey: key1, queryFn: () => 'string', // @ts-expect-error (invalidField) someInvalidField: [], }, ], }) // field names should be enforced - Array.map() result useQueries({ // @ts-expect-error (invalidField) queries: Array(10).map(() => ({ someInvalidField: '', })), }) // field names should be enforced - array literal useQueries({ queries: [ { queryKey: key1, queryFn: () => 'string', // @ts-expect-error (invalidField) someInvalidField: [], }, ], }) // supports queryFn using fetch() to return Promise - Array.map() result useQueries({ queries: Array(50).map((_, i) => ({ queryKey: ['key', i] as const, queryFn: () => fetch('return Promise').then((resp) => resp.json()), })), }) // supports queryFn using fetch() to return Promise - array literal useQueries({ queries: [ { queryKey: key1, queryFn: () => fetch('return Promise').then((resp) => resp.json()), }, ], }) } }) it('handles strongly typed queryFn factories and useQueries wrappers', () => { // QueryKey + queryFn factory type QueryKeyA = ['queryA'] const getQueryKeyA = (): QueryKeyA => ['queryA'] type GetQueryFunctionA = () => QueryFunction const getQueryFunctionA: GetQueryFunctionA = () => async () => { return 1 } type SelectorA = (data: number) => [number, string] const getSelectorA = (): SelectorA => (data) => [data, data.toString()] type QueryKeyB = ['queryB', string] const getQueryKeyB = (id: string): QueryKeyB => ['queryB', id] type GetQueryFunctionB = () => QueryFunction const getQueryFunctionB: GetQueryFunctionB = () => async () => { return '1' } type SelectorB = (data: string) => [string, number] const getSelectorB = (): SelectorB => (data) => [data, +data] // Wrapper with strongly typed array-parameter function useWrappedQueries< TQueryFnData, TError, TData, TQueryKey extends QueryKey, >(queries: UseQueryOptions[]) { return useQueries({ queries: queries.map( // no need to type the mapped query (query) => { const { queryFn: fn, queryKey: key, onError: err } = query expectType | undefined>(fn) return { queryKey: key, onError: err, queryFn: fn ? (ctx: QueryFunctionContext) => { expectType(ctx.queryKey) return fn.call({}, ctx) } : undefined, } }, ), }) } // @ts-expect-error (Page component is not rendered) // eslint-disable-next-line function Page() { const result = useQueries({ queries: [ { queryKey: getQueryKeyA(), queryFn: getQueryFunctionA(), }, { queryKey: getQueryKeyB('id'), queryFn: getQueryFunctionB(), }, ], }) expectType>(result[0]) expectType>(result[1]) const withSelector = useQueries({ queries: [ { queryKey: getQueryKeyA(), queryFn: getQueryFunctionA(), select: getSelectorA(), }, { queryKey: getQueryKeyB('id'), queryFn: getQueryFunctionB(), select: getSelectorB(), }, ], }) expectType>( withSelector[0], ) expectType>( withSelector[1], ) const withWrappedQueries = useWrappedQueries( Array(10).map(() => ({ queryKey: getQueryKeyA(), queryFn: getQueryFunctionA(), select: getSelectorA(), })), ) expectType[]>( withWrappedQueries, ) } }) it('should not change state if unmounted', async () => { const key1 = queryKey() // We have to mock the QueriesObserver to not unsubscribe // the listener when the component is unmounted class QueriesObserverMock extends QueriesObserver { subscribe(listener: any) { super.subscribe(listener) return () => void 0 } } const QueriesObserverSpy = jest .spyOn(QueriesObserverModule, 'QueriesObserver') .mockImplementation((fn) => { return new QueriesObserverMock(fn) }) function Queries() { useQueries({ queries: [ { queryKey: key1, queryFn: async () => { await sleep(10) return 1 }, }, ], }) return (
queries
) } function Page() { const [mounted, setMounted] = React.useState(true) return (
{mounted && }
) } const { getByText } = renderWithClient(queryClient, ) fireEvent.click(getByText('unmount')) // Should not display the console error // "Warning: Can't perform a React state update on an unmounted component" await sleep(20) QueriesObserverSpy.mockRestore() }) describe('with custom context', () => { it('should return the correct states', async () => { const context = React.createContext(undefined) const key1 = queryKey() const key2 = queryKey() const results: UseQueryResult[][] = [] function Page() { const result = useQueries({ context, queries: [ { queryKey: key1, queryFn: async () => { await sleep(5) return 1 }, }, { queryKey: key2, queryFn: async () => { await sleep(10) return 2 }, }, ], }) results.push(result) return (
data1: {result[0].data}
data2: {result[1].data}
) } const rendered = renderWithClient(queryClient, , { context }) await waitFor(() => { rendered.getByText('data1: 1') rendered.getByText('data2: 2') }) expect(results[0]).toMatchObject([ { data: undefined }, { data: undefined }, ]) expect(results[results.length - 1]).toMatchObject([ { data: 1 }, { data: 2 }, ]) }) it('should throw if the context is necessary and is not passed to useQueries', async () => { const context = React.createContext(undefined) const key1 = queryKey() const key2 = queryKey() const results: UseQueryResult[][] = [] function Page() { const result = useQueries({ queries: [ { queryKey: key1, queryFn: async () => 1, }, { queryKey: key2, queryFn: async () => 2, }, ], }) results.push(result) return null } const rendered = renderWithClient( queryClient,
error boundary
}>
, { context }, ) await waitFor(() => rendered.getByText('error boundary')) }) }) it("should throw error if in one of queries' queryFn throws and useErrorBoundary is in use", async () => { const key1 = queryKey() const key2 = queryKey() const key3 = queryKey() const key4 = queryKey() function Page() { useQueries({ queries: [ { queryKey: key1, queryFn: () => Promise.reject( new Error( 'this should not throw because useErrorBoundary is not set', ), ), }, { queryKey: key2, queryFn: () => Promise.reject(new Error('single query error')), useErrorBoundary: true, retry: false, }, { queryKey: key3, queryFn: async () => 2, }, { queryKey: key4, queryFn: async () => Promise.reject( new Error('this should not throw because query#2 already did'), ), useErrorBoundary: true, retry: false, }, ], }) return null } const rendered = renderWithClient( queryClient, (
error boundary
{error.message}
)} >
, ) await waitFor(() => rendered.getByText('error boundary')) await waitFor(() => rendered.getByText('single query error')) }) it("should throw error if in one of queries' queryFn throws and useErrorBoundary function resolves to true", async () => { const key1 = queryKey() const key2 = queryKey() const key3 = queryKey() const key4 = queryKey() function Page() { useQueries({ queries: [ { queryKey: key1, queryFn: () => Promise.reject( new Error( 'this should not throw because useErrorBoundary function resolves to false', ), ), useErrorBoundary: () => false, retry: false, }, { queryKey: key2, queryFn: async () => 2, }, { queryKey: key3, queryFn: () => Promise.reject(new Error('single query error')), useErrorBoundary: () => true, retry: false, }, { queryKey: key4, queryFn: async () => Promise.reject( new Error('this should not throw because query#3 already did'), ), useErrorBoundary: true, retry: false, }, ], }) return null } const rendered = renderWithClient( queryClient, (
error boundary
{error.message}
)} >
, ) await waitFor(() => rendered.getByText('error boundary')) await waitFor(() => rendered.getByText('single query error')) }) })