1654 lines
49 KiB
TypeScript
1654 lines
49 KiB
TypeScript
import { fireEvent, render } from '@testing-library/svelte'
|
|
import { flushSync } from 'svelte'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { queryKey, sleep } from '@tanstack/query-test-utils'
|
|
import {
|
|
QueryClient,
|
|
createQuery,
|
|
keepPreviousData,
|
|
noop,
|
|
} from '../../src/index.js'
|
|
import { promiseWithResolvers, withEffectRoot } from '../utils.svelte.js'
|
|
import Base from './Base.svelte'
|
|
import Counter from './Counter.svelte'
|
|
import IsRestoring from './IsRestoring.svelte'
|
|
import Select from './Select.svelte'
|
|
import TwoQueries from './TwoQueries.svelte'
|
|
import type { CreateQueryResult, QueryCache } from '../../src/index.js'
|
|
|
|
describe('createQuery', () => {
|
|
let queryClient: QueryClient
|
|
let queryCache: QueryCache
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
queryClient = new QueryClient()
|
|
queryCache = queryClient.getQueryCache()
|
|
})
|
|
|
|
afterEach(() => {
|
|
queryClient.clear()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it(
|
|
'should return the correct states for a successful query',
|
|
withEffectRoot(async () => {
|
|
const key = queryKey()
|
|
const { promise, resolve } = promiseWithResolvers<string>()
|
|
|
|
const query = createQuery<string, Error>(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => promise,
|
|
}),
|
|
() => queryClient,
|
|
)
|
|
|
|
expect(query).toEqual({
|
|
data: undefined,
|
|
dataUpdatedAt: 0,
|
|
error: null,
|
|
errorUpdatedAt: 0,
|
|
failureCount: 0,
|
|
failureReason: null,
|
|
errorUpdateCount: 0,
|
|
isEnabled: true,
|
|
isError: false,
|
|
isFetched: false,
|
|
isFetchedAfterMount: false,
|
|
isFetching: true,
|
|
isPaused: false,
|
|
isPending: true,
|
|
isInitialLoading: true,
|
|
isLoading: true,
|
|
isLoadingError: false,
|
|
isPlaceholderData: false,
|
|
isRefetchError: false,
|
|
isRefetching: false,
|
|
isStale: true,
|
|
isSuccess: false,
|
|
refetch: expect.any(Function),
|
|
status: 'pending',
|
|
fetchStatus: 'fetching',
|
|
})
|
|
resolve('resolved')
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(query).toEqual({
|
|
data: 'resolved',
|
|
dataUpdatedAt: expect.any(Number),
|
|
error: null,
|
|
errorUpdatedAt: 0,
|
|
failureCount: 0,
|
|
failureReason: null,
|
|
errorUpdateCount: 0,
|
|
isEnabled: true,
|
|
isError: false,
|
|
isFetched: true,
|
|
isFetchedAfterMount: true,
|
|
isFetching: false,
|
|
isPaused: false,
|
|
isPending: false,
|
|
isInitialLoading: false,
|
|
isLoading: false,
|
|
isLoadingError: false,
|
|
isPlaceholderData: false,
|
|
isRefetchError: false,
|
|
isRefetching: false,
|
|
isStale: true,
|
|
isSuccess: true,
|
|
refetch: expect.any(Function),
|
|
status: 'success',
|
|
fetchStatus: 'idle',
|
|
})
|
|
}),
|
|
)
|
|
|
|
it(
|
|
'should return the correct states for an unsuccessful query',
|
|
withEffectRoot(async () => {
|
|
const key = queryKey()
|
|
let count = 0
|
|
const states: Array<CreateQueryResult> = []
|
|
const query = createQuery<string, Error>(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => {
|
|
return Promise.reject(new Error('rejected #' + ++count))
|
|
},
|
|
retry: 1,
|
|
retryDelay: 1,
|
|
}),
|
|
() => queryClient,
|
|
)
|
|
$effect(() => {
|
|
states.push({ ...query })
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(query.isError).toBe(true)
|
|
|
|
expect(states[0]).toEqual({
|
|
data: undefined,
|
|
dataUpdatedAt: 0,
|
|
error: null,
|
|
errorUpdatedAt: 0,
|
|
failureCount: 0,
|
|
failureReason: null,
|
|
errorUpdateCount: 0,
|
|
isEnabled: true,
|
|
isError: false,
|
|
isFetched: false,
|
|
isFetchedAfterMount: false,
|
|
isFetching: true,
|
|
isPaused: false,
|
|
isPending: true,
|
|
isInitialLoading: true,
|
|
isLoading: true,
|
|
isLoadingError: false,
|
|
isPlaceholderData: false,
|
|
isRefetchError: false,
|
|
isRefetching: false,
|
|
isStale: true,
|
|
isSuccess: false,
|
|
refetch: expect.any(Function),
|
|
status: 'pending',
|
|
fetchStatus: 'fetching',
|
|
})
|
|
|
|
expect(states[1]).toEqual({
|
|
data: undefined,
|
|
dataUpdatedAt: 0,
|
|
error: null,
|
|
errorUpdatedAt: 0,
|
|
failureCount: 1,
|
|
failureReason: new Error('rejected #1'),
|
|
errorUpdateCount: 0,
|
|
isEnabled: true,
|
|
isError: false,
|
|
isFetched: false,
|
|
isFetchedAfterMount: false,
|
|
isFetching: true,
|
|
isPaused: false,
|
|
isPending: true,
|
|
isInitialLoading: true,
|
|
isLoading: true,
|
|
isLoadingError: false,
|
|
isPlaceholderData: false,
|
|
isRefetchError: false,
|
|
isRefetching: false,
|
|
isStale: true,
|
|
isSuccess: false,
|
|
refetch: expect.any(Function),
|
|
status: 'pending',
|
|
fetchStatus: 'fetching',
|
|
})
|
|
|
|
expect(states[2]).toEqual({
|
|
data: undefined,
|
|
dataUpdatedAt: 0,
|
|
error: new Error('rejected #2'),
|
|
errorUpdatedAt: expect.any(Number),
|
|
failureCount: 2,
|
|
failureReason: new Error('rejected #2'),
|
|
errorUpdateCount: 1,
|
|
isEnabled: true,
|
|
isError: true,
|
|
isFetched: true,
|
|
isFetchedAfterMount: true,
|
|
isFetching: false,
|
|
isPaused: false,
|
|
isPending: false,
|
|
isInitialLoading: false,
|
|
isLoading: false,
|
|
isLoadingError: true,
|
|
isPlaceholderData: false,
|
|
isRefetchError: false,
|
|
isRefetching: false,
|
|
isStale: true,
|
|
isSuccess: false,
|
|
refetch: expect.any(Function),
|
|
status: 'error',
|
|
fetchStatus: 'idle',
|
|
})
|
|
}),
|
|
)
|
|
|
|
it('should set isFetchedAfterMount to true after a query has been fetched', async () => {
|
|
const key = queryKey()
|
|
|
|
await queryClient
|
|
.query({
|
|
queryKey: key,
|
|
queryFn: () => Promise.resolve('prefetched'),
|
|
})
|
|
.catch(noop)
|
|
|
|
const { promise, resolve } = promiseWithResolvers<string>()
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => promise,
|
|
}),
|
|
},
|
|
})
|
|
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('prefetched')
|
|
expect(rendered.getByTestId('isFetched')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isFetchedAfterMount')).toHaveTextContent(
|
|
'false',
|
|
)
|
|
|
|
resolve('resolved')
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('resolved')
|
|
expect(rendered.getByTestId('isFetched')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isFetchedAfterMount')).toHaveTextContent(
|
|
'true',
|
|
)
|
|
})
|
|
|
|
it('should keep initialData visible alongside the error when a refetch fails', async () => {
|
|
const key = queryKey()
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () =>
|
|
sleep(10).then(() => Promise.reject(new Error('Some error'))),
|
|
initialData: 'initial',
|
|
retry: false,
|
|
}),
|
|
},
|
|
})
|
|
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('initial')
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('success')
|
|
|
|
await vi.advanceTimersByTimeAsync(11)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('initial')
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('error')
|
|
})
|
|
|
|
it('should not cancel an ongoing fetch when refetch is called with cancelRefetch=false if we have data already', async () => {
|
|
const key = queryKey()
|
|
let fetchCount = 0
|
|
|
|
const { promise, resolve } = promiseWithResolvers<string>()
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => {
|
|
fetchCount++
|
|
return promise
|
|
},
|
|
enabled: false,
|
|
initialData: 'initial',
|
|
}),
|
|
},
|
|
})
|
|
|
|
fireEvent.click(rendered.getByRole('button', { name: /^refetch$/i }))
|
|
fireEvent.click(
|
|
rendered.getByRole('button', { name: /refetch no cancel/i }),
|
|
)
|
|
|
|
resolve('resolved')
|
|
await promise
|
|
|
|
expect(fetchCount).toBe(1)
|
|
})
|
|
|
|
it('should cancel an ongoing fetch when refetch is called (cancelRefetch=true) if we have data already', async () => {
|
|
const key = queryKey()
|
|
let fetchCount = 0
|
|
|
|
const { promise, resolve } = promiseWithResolvers<string>()
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => {
|
|
fetchCount++
|
|
return promise
|
|
},
|
|
enabled: false,
|
|
initialData: 'initialData',
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Trigger two refetch close together
|
|
fireEvent.click(rendered.getByRole('button', { name: /^refetch$/i }))
|
|
fireEvent.click(rendered.getByRole('button', { name: /^refetch$/i }))
|
|
|
|
resolve('resolved')
|
|
await promise
|
|
|
|
expect(fetchCount).toBe(2)
|
|
})
|
|
|
|
it('should not cancel an ongoing fetch when refetch is called (cancelRefetch=true) if we do not have data yet', async () => {
|
|
const key = queryKey()
|
|
let fetchCount = 0
|
|
|
|
const { promise, resolve } = promiseWithResolvers<string>()
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => {
|
|
fetchCount++
|
|
return promise
|
|
},
|
|
enabled: false,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Trigger two refetch close together
|
|
fireEvent.click(rendered.getByRole('button', { name: /^refetch$/i }))
|
|
fireEvent.click(rendered.getByRole('button', { name: /^refetch$/i }))
|
|
|
|
resolve('resolved')
|
|
await promise
|
|
|
|
expect(fetchCount).toBe(1)
|
|
})
|
|
|
|
it('should be able to watch a query without providing a query function', async () => {
|
|
const key = queryKey()
|
|
|
|
queryClient.setQueryDefaults(key, {
|
|
queryFn: () => 'data',
|
|
})
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({ queryKey: key }),
|
|
},
|
|
})
|
|
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('data')
|
|
})
|
|
|
|
it('should pick up a query when re-mounting with gcTime 0', async () => {
|
|
const key = queryKey()
|
|
// this needs to be split into two different effect roots because
|
|
// effects won't pick up dependencies created after the first `await`
|
|
// -- the two roots effectively emulate two consecutive components being rendered
|
|
await withEffectRoot(async () => {
|
|
const { promise, resolve } = promiseWithResolvers<string>()
|
|
|
|
const query = createQuery<string>(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => promise,
|
|
gcTime: 0,
|
|
notifyOnChangeProps: 'all',
|
|
}),
|
|
() => queryClient,
|
|
)
|
|
|
|
expect(query).toMatchObject({
|
|
isPending: true,
|
|
isSuccess: false,
|
|
isFetching: true,
|
|
})
|
|
|
|
resolve('resolved: 1')
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(query.data).toBe('resolved: 1')
|
|
|
|
expect(query).toMatchObject({
|
|
isPending: false,
|
|
isSuccess: true,
|
|
isFetching: false,
|
|
})
|
|
})()
|
|
|
|
await withEffectRoot(async () => {
|
|
const { promise, resolve } = promiseWithResolvers<string>()
|
|
|
|
const query = createQuery<string>(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => promise,
|
|
gcTime: 0,
|
|
notifyOnChangeProps: 'all',
|
|
}),
|
|
() => queryClient,
|
|
)
|
|
|
|
expect(query).toMatchObject({
|
|
data: 'resolved: 1',
|
|
isPending: false,
|
|
isSuccess: true,
|
|
isFetching: true,
|
|
})
|
|
|
|
resolve('resolved: 2')
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(query.data).toBe('resolved: 2')
|
|
|
|
expect(query).toMatchObject({
|
|
data: 'resolved: 2',
|
|
isPending: false,
|
|
isSuccess: true,
|
|
isFetching: false,
|
|
})
|
|
})()
|
|
})
|
|
|
|
it('should not get into an infinite loop when removing a query with gcTime 0 and rerendering', async () => {
|
|
const key = queryKey()
|
|
const states: Array<CreateQueryResult<string>> = []
|
|
|
|
// First mount: render the query and let it fetch
|
|
await withEffectRoot(async () => {
|
|
const query = createQuery<string>(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => Promise.resolve('data'),
|
|
gcTime: 0,
|
|
notifyOnChangeProps: ['isPending', 'isSuccess', 'data'],
|
|
}),
|
|
() => queryClient,
|
|
)
|
|
|
|
$effect(() => {
|
|
states.push({ ...query })
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(query.data).toBe('data')
|
|
})()
|
|
|
|
// Simulate rerender by removing the query and mounting again
|
|
await withEffectRoot(async () => {
|
|
queryClient.removeQueries({ queryKey: key })
|
|
|
|
const query = createQuery<string>(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => Promise.resolve('data'),
|
|
gcTime: 0,
|
|
notifyOnChangeProps: ['isPending', 'isSuccess', 'data'],
|
|
}),
|
|
() => queryClient,
|
|
)
|
|
|
|
$effect(() => {
|
|
states.push({ ...query })
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(query.data).toBe('data')
|
|
|
|
// Give it time to catch any accidental infinite updates
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
})()
|
|
|
|
expect(states.length).toBe(4)
|
|
expect(states[0]).toMatchObject({
|
|
isPending: true,
|
|
isSuccess: false,
|
|
data: undefined,
|
|
})
|
|
expect(states[1]).toMatchObject({
|
|
isPending: false,
|
|
isSuccess: true,
|
|
data: 'data',
|
|
})
|
|
expect(states[2]).toMatchObject({
|
|
isPending: true,
|
|
isSuccess: false,
|
|
data: undefined,
|
|
})
|
|
expect(states[3]).toMatchObject({
|
|
isPending: false,
|
|
isSuccess: true,
|
|
data: 'data',
|
|
})
|
|
})
|
|
|
|
it('should fetch when refetchOnMount is false and nothing has been fetched yet', async () => {
|
|
const key = queryKey()
|
|
const queryFn = vi.fn(() => 'test')
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn,
|
|
refetchOnMount: false,
|
|
}),
|
|
},
|
|
})
|
|
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('test')
|
|
// queryFn should be called since nothing has been fetched yet
|
|
expect(queryFn).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should not fetch when refetchOnMount is false and data has been fetched already', async () => {
|
|
const key = queryKey()
|
|
const queryFn = vi.fn(() => 'test')
|
|
|
|
queryClient.setQueryData(key, 'prefetched')
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn,
|
|
refetchOnMount: false,
|
|
}),
|
|
},
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('prefetched')
|
|
// queryFn should not be called since data was already fetched
|
|
expect(queryFn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should be able to select a part of the data with select', async () => {
|
|
const key = queryKey()
|
|
|
|
const rendered = render(Select, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => ({ name: 'test' }),
|
|
select: (data: { name: string }) => data.name,
|
|
}),
|
|
},
|
|
})
|
|
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('test')
|
|
})
|
|
|
|
it('should throw an error when a selector throws', async () => {
|
|
const key = queryKey()
|
|
const error = new Error('Select Error')
|
|
|
|
const rendered = render(Select, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => ({ name: 'test' }),
|
|
select: () => {
|
|
throw error
|
|
},
|
|
}),
|
|
},
|
|
})
|
|
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('pending')
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('error')
|
|
expect(rendered.getByTestId('error')).toHaveTextContent('Select Error')
|
|
})
|
|
|
|
it('should be able to remove a query', async () => {
|
|
const key = queryKey()
|
|
let count = 0
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => ++count,
|
|
notifyOnChangeProps: 'all',
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Initial: pending
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('pending')
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('success')
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('1')
|
|
|
|
// Remove the query, then refetch — it should reset to pending first
|
|
queryClient.removeQueries({ queryKey: key })
|
|
fireEvent.click(rendered.getByRole('button', { name: /^refetch$/i }))
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('pending')
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
expect(rendered.getByTestId('dataUpdatedAt')).toHaveTextContent('0')
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('success')
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('2')
|
|
})
|
|
|
|
it('keeps up-to-date with query key changes', async () => {
|
|
const key = queryKey()
|
|
|
|
const rendered = render(Counter, {
|
|
props: {
|
|
queryClient,
|
|
options: (count: number) => ({
|
|
queryKey: [...key, count],
|
|
queryFn: () => sleep(10).then(() => count),
|
|
placeholderData: keepPreviousData,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Initial fetch
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('pending')
|
|
expect(rendered.getByTestId('fetchStatus')).toHaveTextContent('fetching')
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('success')
|
|
expect(rendered.getByTestId('fetchStatus')).toHaveTextContent('idle')
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
|
|
// Change the key — previous data is kept while the new key fetches
|
|
fireEvent.click(rendered.getByRole('button', { name: /increment/i }))
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('success')
|
|
expect(rendered.getByTestId('fetchStatus')).toHaveTextContent('fetching')
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('success')
|
|
expect(rendered.getByTestId('fetchStatus')).toHaveTextContent('idle')
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('1')
|
|
})
|
|
|
|
it('should create a new query when refetching a removed query', async () => {
|
|
const key = queryKey()
|
|
let count = 0
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => sleep(10).then(() => ++count),
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Initial
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('1')
|
|
|
|
// Remove the query, then refetch — a new query starts from scratch
|
|
queryClient.removeQueries({ queryKey: key })
|
|
fireEvent.click(rendered.getByRole('button', { name: /^refetch$/i }))
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
expect(rendered.getByTestId('dataUpdatedAt')).toHaveTextContent('0')
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('2')
|
|
})
|
|
|
|
it(
|
|
'should share equal data structures between query results',
|
|
withEffectRoot(async () => {
|
|
const key = queryKey()
|
|
|
|
const result1 = [
|
|
{ id: '1', done: false },
|
|
{ id: '2', done: false },
|
|
]
|
|
|
|
const result2 = [
|
|
{ id: '1', done: false },
|
|
{ id: '2', done: true },
|
|
]
|
|
|
|
const states: Array<CreateQueryResult<typeof result1>> = []
|
|
|
|
let count = 0
|
|
|
|
const query = createQuery<typeof result1>(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => {
|
|
count++
|
|
return Promise.resolve(count === 1 ? result1 : result2)
|
|
},
|
|
}),
|
|
() => queryClient,
|
|
)
|
|
|
|
$effect(() => {
|
|
states.push({ ...query })
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(query.data?.[1]?.done).toBe(false)
|
|
query.refetch()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(query.data?.[1]?.done).toBe(true)
|
|
|
|
expect(states.length).toBe(4)
|
|
|
|
const todos = states[1]?.data
|
|
const todo1 = todos?.[0]
|
|
const todo2 = todos?.[1]
|
|
|
|
const newTodos = states[3]?.data
|
|
const newTodo1 = newTodos?.[0]
|
|
const newTodo2 = newTodos?.[1]
|
|
|
|
expect(todos).toEqual(result1)
|
|
expect(newTodos).toEqual(result2)
|
|
expect(newTodos).not.toBe(todos)
|
|
expect(newTodo1).toBe(todo1)
|
|
expect(newTodo2).not.toBe(todo2)
|
|
}),
|
|
)
|
|
|
|
it('should use query function from hook when the existing query does not have a query function', async () => {
|
|
const key = queryKey()
|
|
|
|
queryClient.setQueryData(key, 'set')
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => sleep(10).then(() => 'fetched'),
|
|
initialData: 'initial',
|
|
staleTime: Infinity,
|
|
}),
|
|
},
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('set')
|
|
queryClient.refetchQueries({ queryKey: key })
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('fetched')
|
|
})
|
|
|
|
it('should update query stale state and refetch when invalidated with invalidateQueries', async () => {
|
|
const key = queryKey()
|
|
let count = 0
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => sleep(10).then(() => ++count),
|
|
staleTime: Infinity,
|
|
}),
|
|
},
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('1')
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
|
|
queryClient.invalidateQueries({ queryKey: key })
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('2')
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
})
|
|
|
|
it('should not update disabled query when refetching with refetchQueries', async () => {
|
|
const key = queryKey()
|
|
const queryFn = vi.fn(() => sleep(10).then(() => 1))
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn,
|
|
enabled: false,
|
|
}),
|
|
},
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
|
|
// Disabled query never fetches
|
|
expect(queryFn).not.toHaveBeenCalled()
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('false')
|
|
})
|
|
|
|
it('should not refetch disabled query when invalidated with invalidateQueries', async () => {
|
|
const key = queryKey()
|
|
const queryFn = vi.fn(() => sleep(10).then(() => 1))
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn,
|
|
enabled: false,
|
|
}),
|
|
},
|
|
})
|
|
|
|
queryClient.invalidateQueries({ queryKey: key })
|
|
|
|
// Wait long enough for the invalidation and potential refetch
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
|
|
// Disabled query does not fetch on invalidation
|
|
expect(queryFn).not.toHaveBeenCalled()
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('false')
|
|
})
|
|
|
|
it('should not fetch when switching to a disabled query', async () => {
|
|
const key = queryKey()
|
|
|
|
const rendered = render(Counter, {
|
|
props: {
|
|
queryClient,
|
|
options: (count: number) => ({
|
|
queryKey: [...key, count],
|
|
queryFn: () => sleep(10).then(() => count),
|
|
enabled: count === 0,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Enabled query fetches
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('false')
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('true')
|
|
|
|
// Switch to a disabled query — it should not fetch
|
|
fireEvent.click(rendered.getByRole('button', { name: /increment/i }))
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('false')
|
|
})
|
|
|
|
it('should keep the previous data when placeholderData is set', async () => {
|
|
const key = queryKey()
|
|
|
|
const rendered = render(Counter, {
|
|
props: {
|
|
queryClient,
|
|
options: (count: number) => ({
|
|
queryKey: [...key, count],
|
|
queryFn: () => sleep(10).then(() => count),
|
|
placeholderData: keepPreviousData,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Initial fetch
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('false')
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('false')
|
|
|
|
// Update count to trigger a new fetch — previous data is kept as placeholder
|
|
fireEvent.click(rendered.getByRole('button', { name: /increment/i }))
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('true')
|
|
|
|
// New data comes in
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('1')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('false')
|
|
})
|
|
|
|
it('should not show initial data from next query if placeholderData is set', async () => {
|
|
const key = queryKey()
|
|
|
|
const rendered = render(Counter, {
|
|
props: {
|
|
queryClient,
|
|
options: (count: number) => ({
|
|
queryKey: [...key, count],
|
|
queryFn: () => sleep(10).then(() => count),
|
|
placeholderData: keepPreviousData,
|
|
initialData: 99,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Initial: uses initialData
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('99')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('false')
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('false')
|
|
|
|
// Update count — next query uses its own initialData (99), not placeholder
|
|
fireEvent.click(rendered.getByRole('button', { name: /increment/i }))
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('99')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('false')
|
|
|
|
// New data comes in
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('1')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('false')
|
|
})
|
|
|
|
it('should keep the previous data on disabled query when placeholderData is set and switching query key multiple times', async () => {
|
|
const key = queryKey()
|
|
|
|
// Set initial query data
|
|
queryClient.setQueryData([...key, 10], 10)
|
|
|
|
const rendered = render(Counter, {
|
|
props: {
|
|
queryClient,
|
|
startCount: 10,
|
|
options: (count: number) => ({
|
|
queryKey: [...key, count],
|
|
queryFn: () => sleep(10).then(() => count),
|
|
enabled: false,
|
|
placeholderData: keepPreviousData,
|
|
notifyOnChangeProps: 'all',
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Disabled query shows the cached data for key 10
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('10')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('false')
|
|
|
|
// Switch key (11) — keeps previous data as placeholder
|
|
fireEvent.click(rendered.getByRole('button', { name: /increment/i }))
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('10')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('true')
|
|
|
|
// Switch key again (12) — still keeps previous data as placeholder
|
|
fireEvent.click(rendered.getByRole('button', { name: /increment/i }))
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('10')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('true')
|
|
|
|
// Refetch — fetching while keeping placeholder
|
|
fireEvent.click(rendered.getByRole('button', { name: /^refetch$/i }))
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('10')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('true')
|
|
|
|
// Refetch done — new data for key 12
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('12')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isSuccess')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isPlaceholderData')).toHaveTextContent('false')
|
|
})
|
|
|
|
it('should use the correct query function when components use different configurations', async () => {
|
|
const key = queryKey()
|
|
const { promise, resolve } = promiseWithResolvers<number>()
|
|
|
|
const rendered = render(TwoQueries, {
|
|
props: {
|
|
queryClient,
|
|
// FirstComponent
|
|
options1: () => ({
|
|
queryKey: key,
|
|
queryFn: () => promise,
|
|
}),
|
|
// SecondComponent
|
|
options2: () => ({
|
|
queryKey: key,
|
|
queryFn: () => 2,
|
|
}),
|
|
},
|
|
})
|
|
|
|
expect(rendered.getByTestId('data1')).toHaveTextContent('undefined')
|
|
|
|
// Resolve the first query
|
|
resolve(1)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data1')).toHaveTextContent('1')
|
|
|
|
// Refetch the first query — it should use the first query function (1), not the second (2)
|
|
fireEvent.click(rendered.getByRole('button', { name: /refetch1/i }))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data1')).toHaveTextContent('1')
|
|
})
|
|
|
|
it('should be able to set different stale times for a query', async () => {
|
|
const key = queryKey()
|
|
|
|
// Prefetch the query
|
|
const prefetchPromise = queryClient
|
|
.query({
|
|
queryKey: key,
|
|
queryFn: () => sleep(10).then(() => 'prefetch'),
|
|
})
|
|
.catch(noop)
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await prefetchPromise
|
|
|
|
expect(queryClient.getQueryState(key)?.data).toBe('prefetch')
|
|
// Advance time so secondQuery (staleTime: 10) sees prefetched data as stale
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
|
|
const rendered = render(TwoQueries, {
|
|
props: {
|
|
queryClient,
|
|
options1: () => ({
|
|
queryKey: key,
|
|
queryFn: () => sleep(10).then(() => 'one'),
|
|
staleTime: 100,
|
|
}),
|
|
options2: () => ({
|
|
queryKey: key,
|
|
queryFn: () => sleep(10).then(() => 'two'),
|
|
staleTime: 10,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Initial: firstQuery (staleTime 100) sees prefetch as fresh,
|
|
// secondQuery (staleTime 10) sees prefetch as stale
|
|
expect(rendered.getByTestId('data1')).toHaveTextContent('prefetch')
|
|
expect(rendered.getByTestId('isStale1')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('data2')).toHaveTextContent('prefetch')
|
|
expect(rendered.getByTestId('isStale2')).toHaveTextContent('true')
|
|
|
|
// After the refetch triggered by the stale second query
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(rendered.getByTestId('data1')).toHaveTextContent('two')
|
|
expect(rendered.getByTestId('isStale1')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('data2')).toHaveTextContent('two')
|
|
expect(rendered.getByTestId('isStale2')).toHaveTextContent('false')
|
|
|
|
// After both staleTimes expire (100ms for firstQuery)
|
|
await vi.advanceTimersByTimeAsync(101)
|
|
expect(rendered.getByTestId('data1')).toHaveTextContent('two')
|
|
expect(rendered.getByTestId('isStale1')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('data2')).toHaveTextContent('two')
|
|
expect(rendered.getByTestId('isStale2')).toHaveTextContent('true')
|
|
})
|
|
|
|
it('should re-render when a query becomes stale', async () => {
|
|
const key = queryKey()
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => 'test',
|
|
staleTime: 50,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Pending: stale
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('true')
|
|
|
|
// Fetched: fresh within staleTime
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('test')
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('false')
|
|
|
|
// After staleTime: stale again
|
|
await vi.advanceTimersByTimeAsync(51)
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('true')
|
|
})
|
|
|
|
it(
|
|
'should not re-render when it should only re-render on data changes and the data did not change',
|
|
withEffectRoot(async () => {
|
|
const key = queryKey()
|
|
const states: Array<CreateQueryResult<string>> = []
|
|
const { promise, resolve } = promiseWithResolvers<string>()
|
|
|
|
const query = createQuery<string>(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => promise,
|
|
notifyOnChangeProps: ['data'],
|
|
}),
|
|
() => queryClient,
|
|
)
|
|
|
|
$effect(() => {
|
|
states.push({ ...query })
|
|
})
|
|
|
|
resolve('test')
|
|
|
|
// Refetch the query
|
|
setTimeout(() => {
|
|
query.refetch()
|
|
}, 10)
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(states.length).toBe(2)
|
|
|
|
expect(states[0]).toMatchObject({
|
|
data: undefined,
|
|
status: 'pending',
|
|
isFetching: true,
|
|
})
|
|
expect(states[1]).toMatchObject({
|
|
data: 'test',
|
|
status: 'success',
|
|
isFetching: false,
|
|
})
|
|
}),
|
|
)
|
|
|
|
it(
|
|
'should track properties and only re-render when a tracked property changes',
|
|
withEffectRoot(async () => {
|
|
const key = queryKey()
|
|
const states: Array<string | undefined> = []
|
|
const { promise, resolve } = promiseWithResolvers<string>()
|
|
|
|
const query = createQuery<string>(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => promise,
|
|
}),
|
|
() => queryClient,
|
|
)
|
|
|
|
$effect(() => {
|
|
states.push(query.data)
|
|
})
|
|
|
|
// Resolve the promise after a delay
|
|
setTimeout(() => {
|
|
resolve('test')
|
|
}, 10)
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(query.data).toBe('test')
|
|
|
|
// Refetch after data is available
|
|
setTimeout(() => {
|
|
if (query.data) {
|
|
query.refetch()
|
|
}
|
|
}, 20)
|
|
|
|
// Wait for refetch to complete
|
|
await vi.advanceTimersByTimeAsync(20)
|
|
|
|
expect(states.length).toBe(2)
|
|
expect(states[0]).toBe(undefined)
|
|
expect(states[1]).toBe('test')
|
|
}),
|
|
)
|
|
|
|
it(
|
|
'should always re-render if we are tracking props but not using any',
|
|
withEffectRoot(async () => {
|
|
const key = queryKey()
|
|
let renderCount = 0
|
|
const states: Array<CreateQueryResult<string>> = []
|
|
|
|
const query = createQuery<string>(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => Promise.resolve('test'),
|
|
}),
|
|
() => queryClient,
|
|
)
|
|
|
|
$effect(() => {
|
|
states.push({ ...query })
|
|
})
|
|
|
|
// Track changes to the query state
|
|
$effect(() => {
|
|
// @ts-expect-error
|
|
const _ = { ...query }
|
|
renderCount++
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(query.data).toBe('test')
|
|
|
|
expect(renderCount).toBe(2)
|
|
expect(states.length).toBe(2)
|
|
expect(states[0]).toMatchObject({ data: undefined })
|
|
expect(states[1]).toMatchObject({ data: 'test' })
|
|
}),
|
|
)
|
|
|
|
it(
|
|
'should update query options',
|
|
withEffectRoot(() => {
|
|
const key = queryKey()
|
|
|
|
const queryFn = () => sleep(10).then(() => 'data1')
|
|
|
|
// Create two queries with the same key but different options
|
|
createQuery(
|
|
() => ({ queryKey: key, queryFn, retryDelay: 10 }),
|
|
() => queryClient,
|
|
)
|
|
|
|
createQuery(
|
|
() => ({ queryKey: key, queryFn, retryDelay: 20 }),
|
|
() => queryClient,
|
|
)
|
|
|
|
// The last options should win
|
|
expect(queryCache.find({ queryKey: key })!.options.retryDelay).toBe(20)
|
|
}),
|
|
)
|
|
|
|
it('should start with status pending, fetchStatus idle if enabled is false', async () => {
|
|
const key1 = queryKey()
|
|
const key2 = queryKey()
|
|
|
|
const rendered = render(TwoQueries, {
|
|
props: {
|
|
queryClient,
|
|
options1: () => ({
|
|
queryKey: key1,
|
|
queryFn: () => 'data',
|
|
enabled: false,
|
|
}),
|
|
options2: () => ({
|
|
queryKey: key2,
|
|
queryFn: () => 'data',
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Disabled query stays pending/idle
|
|
expect(rendered.getByTestId('status1')).toHaveTextContent('pending')
|
|
expect(rendered.getByTestId('fetchStatus1')).toHaveTextContent('idle')
|
|
|
|
// Enabled query starts pending/fetching
|
|
expect(rendered.getByTestId('status2')).toHaveTextContent('pending')
|
|
expect(rendered.getByTestId('fetchStatus2')).toHaveTextContent('fetching')
|
|
|
|
// Enabled query completes
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('status2')).toHaveTextContent('success')
|
|
expect(rendered.getByTestId('fetchStatus2')).toHaveTextContent('idle')
|
|
|
|
// Disabled query is still pending/idle
|
|
expect(rendered.getByTestId('status1')).toHaveTextContent('pending')
|
|
expect(rendered.getByTestId('fetchStatus1')).toHaveTextContent('idle')
|
|
})
|
|
|
|
it('should be in "pending" state by default', () => {
|
|
const key = queryKey()
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => new Promise(() => {}),
|
|
}),
|
|
},
|
|
})
|
|
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('pending')
|
|
})
|
|
|
|
it('should not refetch query on focus when `enabled` is set to `false`', async () => {
|
|
const key = queryKey()
|
|
const queryFn = vi.fn().mockReturnValue('data')
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn,
|
|
enabled: false,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Wait a bit to ensure the query has time to settle
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
|
|
// Simulate window focus
|
|
window.dispatchEvent(new Event('visibilitychange'))
|
|
|
|
// Wait a bit more to ensure no refetch happens
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
|
|
// The query function should not have been called
|
|
expect(queryFn).not.toHaveBeenCalled()
|
|
|
|
// Data should be undefined since the query is disabled
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
})
|
|
|
|
it('should not refetch stale query on focus when `refetchOnWindowFocus` is set to `false`', async () => {
|
|
const key = queryKey()
|
|
let count = 0
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => count++,
|
|
staleTime: 0,
|
|
refetchOnWindowFocus: false,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Wait for the initial fetch to complete
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
|
|
// Simulate window focus
|
|
window.dispatchEvent(new Event('visibilitychange'))
|
|
|
|
// Wait a bit to ensure no refetch happens
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
|
|
// Count should still be 1 since no refetch occurred
|
|
expect(count).toBe(1)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
})
|
|
|
|
it('should not refetch stale query on focus when `refetchOnWindowFocus` is set to a function that returns `false`', async () => {
|
|
const key = queryKey()
|
|
let count = 0
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => count++,
|
|
staleTime: 0,
|
|
refetchOnWindowFocus: () => false,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Wait for the initial fetch to complete
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
|
|
// Simulate window focus
|
|
window.dispatchEvent(new Event('visibilitychange'))
|
|
|
|
// Wait a bit to ensure no refetch happens
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
|
|
// Count should still be 1 since no refetch occurred
|
|
expect(count).toBe(1)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
})
|
|
|
|
it('should not refetch fresh query on focus when `refetchOnWindowFocus` is set to `true`', async () => {
|
|
const key = queryKey()
|
|
let count = 0
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => count++,
|
|
staleTime: Infinity,
|
|
refetchOnWindowFocus: true,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Wait for the initial fetch to complete
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
|
|
// Simulate window focus
|
|
window.dispatchEvent(new Event('visibilitychange'))
|
|
|
|
// Wait a bit to ensure no refetch happens
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
|
|
// Count should still be 1 since the fresh query does not refetch
|
|
expect(count).toBe(1)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('0')
|
|
})
|
|
|
|
it('should refetch fresh query when refetchOnMount is set to always', async () => {
|
|
const key = queryKey()
|
|
|
|
// Prefetch the query
|
|
await queryClient
|
|
.query({
|
|
queryKey: key,
|
|
queryFn: () => 'prefetched',
|
|
})
|
|
.catch(noop)
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => 'data',
|
|
refetchOnMount: 'always',
|
|
staleTime: Infinity,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Initial: prefetched data, fresh, but refetching because refetchOnMount: 'always'
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('prefetched')
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('true')
|
|
|
|
// After refetch
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('data')
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('false')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
})
|
|
|
|
it('should refetch stale query when refetchOnMount is set to true', async () => {
|
|
const key = queryKey()
|
|
|
|
// Prefetch the query
|
|
await queryClient
|
|
.query({
|
|
queryKey: key,
|
|
queryFn: () => 'prefetched',
|
|
})
|
|
.catch(noop)
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => 'data',
|
|
refetchOnMount: true,
|
|
staleTime: 0,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Initial: prefetched data, stale, refetching
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('prefetched')
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('true')
|
|
|
|
// After refetch
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('data')
|
|
expect(rendered.getByTestId('isStale')).toHaveTextContent('true')
|
|
expect(rendered.getByTestId('isFetching')).toHaveTextContent('false')
|
|
})
|
|
|
|
it('should set status to error if queryFn throws', async () => {
|
|
const key = queryKey()
|
|
const consoleMock = vi
|
|
.spyOn(console, 'error')
|
|
.mockImplementation(() => undefined)
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => Promise.reject(new Error('Error test')),
|
|
retry: false,
|
|
}),
|
|
},
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('error')
|
|
expect(rendered.getByTestId('error')).toHaveTextContent('Error test')
|
|
|
|
consoleMock.mockRestore()
|
|
})
|
|
|
|
it('should set status to error instead of throwing when error should not be thrown', async () => {
|
|
const key = queryKey()
|
|
|
|
const rendered = render(Base, {
|
|
props: {
|
|
queryClient,
|
|
options: () => ({
|
|
queryKey: key,
|
|
queryFn: () => Promise.reject(new Error('Local Error')),
|
|
retry: false,
|
|
throwOnError: (err: Error) => err.message !== 'Local Error',
|
|
}),
|
|
},
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('error')
|
|
expect(rendered.getByTestId('error')).toHaveTextContent('Local Error')
|
|
})
|
|
|
|
it(
|
|
'should support changing provided query client',
|
|
withEffectRoot(() => {
|
|
const queryClient1 = new QueryClient()
|
|
const queryClient2 = new QueryClient()
|
|
|
|
let currentClient = $state(queryClient1)
|
|
|
|
const key = queryKey()
|
|
|
|
createQuery(
|
|
() => ({
|
|
queryKey: key,
|
|
queryFn: () => Promise.resolve('prefetched'),
|
|
}),
|
|
() => currentClient,
|
|
)
|
|
|
|
expect(
|
|
queryClient1.getQueryCache().find({ queryKey: key })?.queryKey,
|
|
).toEqual(key)
|
|
|
|
currentClient = queryClient2
|
|
flushSync()
|
|
|
|
expect(
|
|
queryClient2.getQueryCache().find({ queryKey: key })?.queryKey,
|
|
).toEqual(key)
|
|
}),
|
|
)
|
|
|
|
it('should not fetch for the duration of the restoring period when isRestoring is true', async () => {
|
|
const queryFn = vi.fn(() => sleep(10).then(() => 'data'))
|
|
|
|
const rendered = render(IsRestoring, {
|
|
props: { queryClient, queryFn },
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('pending')
|
|
expect(rendered.getByTestId('fetchStatus')).toHaveTextContent('idle')
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
expect(queryFn).toHaveBeenCalledTimes(0)
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
|
|
expect(rendered.getByTestId('status')).toHaveTextContent('pending')
|
|
expect(rendered.getByTestId('fetchStatus')).toHaveTextContent('idle')
|
|
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
|
|
expect(queryFn).toHaveBeenCalledTimes(0)
|
|
})
|
|
})
|