diff --git a/src/core/index.ts b/src/core/index.ts index a87a2ebc20..fdb7dc2b3d 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,6 +1,13 @@ export { queryCache, queryCaches, makeQueryCache } from './queryCache' export { setFocusHandler } from './setFocusHandler' -export { stableStringify, setConsole, deepIncludes } from './utils' +export { + CancelledError, + deepIncludes, + isCancelledError, + isError, + setConsole, + stableStringify, +} from './utils' // Types export * from './types' diff --git a/src/core/query.ts b/src/core/query.ts index 5f7db14527..33a62d5e8b 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -1,13 +1,15 @@ import { - isServer, - functionalUpdate, - cancelledError, - isDocumentVisible, - noop, + CancelledError, Console, - getStatusProps, Updater, + functionalUpdate, + getStatusProps, + isCancelable, + isCancelledError, + isDocumentVisible, + isServer, replaceEqualDeep, + sleep, } from './utils' import { ArrayQueryKey, @@ -59,7 +61,7 @@ export interface FetchMoreOptions { } export interface RefetchOptions { - throwOnError?: boolean; + throwOnError?: boolean } export enum ActionType { @@ -91,7 +93,6 @@ interface SuccessAction { interface ErrorAction { type: ActionType.Error - cancelled: boolean error: TError } @@ -111,22 +112,21 @@ export type Action = // CLASS export class Query { - queryCache: QueryCache queryKey: ArrayQueryKey queryHash: string config: QueryConfig observers: QueryObserver[] state: QueryState - shouldContinueRetryOnFocus?: boolean - promise?: Promise + private queryCache: QueryCache + private promise?: Promise private fetchMoreVariable?: unknown private pageVariables?: ArrayQueryKey[] private cacheTimeout?: number - private retryTimeout?: number private staleTimeout?: number - private cancelPromises?: () => void - private cancelled?: typeof cancelledError | null + private cancelFetch?: () => void + private continueFetch?: () => void + private isTransportCancelable?: boolean private notifyGlobalListeners: (query: Query) => void constructor(init: QueryInitConfig) { @@ -161,12 +161,9 @@ export class Query { if (!isServer && this.state.data) { this.scheduleStaleTimeout() - // Simulate a query healing process - this.heal() - // Schedule for garbage collection in case // nothing subscribes to this query - this.scheduleGarbageCollection() + this.scheduleCacheTimeout() } } @@ -180,7 +177,7 @@ export class Query { this.notifyGlobalListeners(this) } - scheduleStaleTimeout(): void { + private scheduleStaleTimeout(): void { if (isServer) { return } @@ -206,7 +203,7 @@ export class Query { this.dispatch({ type: ActionType.MarkStale }) } - scheduleGarbageCollection(): void { + private scheduleCacheTimeout(): void { if (isServer) { return } @@ -217,15 +214,9 @@ export class Query { return } - this.cacheTimeout = setTimeout( - () => { - this.clear() - }, - typeof this.state.data === 'undefined' && - this.state.status !== QueryStatus.Error - ? 0 - : this.config.cacheTime - ) + this.cacheTimeout = setTimeout(() => { + this.clear() + }, this.config.cacheTime) } async refetch(options?: RefetchOptions): Promise { @@ -233,29 +224,18 @@ export class Query { return await this.fetch() } catch (error) { if (options?.throwOnError === true) { - throw error; + throw error } - Console.error(error) + return undefined } - return; - } - - heal(): void { - // Stop the query from being garbage collected - this.clearCacheTimeout() - - // Mark the query as not cancelled - this.cancelled = null } cancel(): void { - this.cancelled = cancelledError - - if (this.cancelPromises) { - this.cancelPromises() - } + this.cancelFetch?.() + } - delete this.promise + continue(): void { + this.continueFetch?.() } private clearTimersObservers(): void { @@ -278,13 +258,6 @@ export class Query { } } - private clearRetryTimeout() { - if (this.retryTimeout) { - clearTimeout(this.retryTimeout) - this.retryTimeout = undefined - } - } - private setState( updater: Updater, QueryState> ): void { @@ -325,7 +298,6 @@ export class Query { clear(): void { this.clearStaleTimeout() this.clearCacheTimeout() - this.clearRetryTimeout() this.clearTimersObservers() this.cancel() delete this.queryCache.queries[this.queryHash] @@ -360,7 +332,9 @@ export class Query { subscribeObserver(observer: QueryObserver): void { this.observers.push(observer) - this.heal() + + // Stop the query from being garbage collected + this.clearCacheTimeout() } unsubscribeObserver( @@ -370,101 +344,141 @@ export class Query { this.observers = this.observers.filter(x => x !== observer) if (!this.observers.length) { - this.cancel() + // If the transport layer does not support cancellation + // we'll let the query continue so the result can be cached + if (this.isTransportCancelable) { + this.cancel() + } if (!preventGC) { // Schedule garbage collection - this.scheduleGarbageCollection() + this.scheduleCacheTimeout() } } } // Set up the core fetcher function - private async tryFetchData( + private async fetchData( fn: QueryFunction, args: ArrayQueryKey ): Promise { - try { - // Perform the query - const filter = this.config.queryFnParamsFilter - const params = filter ? filter(args) : args + return new Promise((outerResolve, outerReject) => { + let resolved = false + let continueLoop: () => void + let cancelTransport: () => void - // Perform the query - const promiseOrValue = fn(...params) + const done = () => { + resolved = true - this.cancelPromises = () => (promiseOrValue as any)?.cancel?.() + delete this.cancelFetch + delete this.continueFetch + delete this.isTransportCancelable - const data = await promiseOrValue - delete this.shouldContinueRetryOnFocus + // End loop if currently paused + continueLoop?.() + } - delete this.cancelPromises - if (this.cancelled) throw this.cancelled + const resolve = (value: any) => { + done() + outerResolve(value) + } - return data - } catch (error) { - delete this.cancelPromises - if (this.cancelled) throw this.cancelled - - // Do we need to retry the request? - if ( - this.config.retry === true || - this.state.failureCount < this.config.retry! || - (typeof this.config.retry === 'function' && - this.config.retry(this.state.failureCount, error)) - ) { - // If we retry, increase the failureCount - this.dispatch({ type: ActionType.Failed }) - - // Only retry if the document is visible - if (!isDocumentVisible()) { - // set this flag to continue retries on focus - this.shouldContinueRetryOnFocus = true - // Resolve a - return new Promise(noop) - } + const reject = (value: any) => { + done() + outerReject(value) + } - delete this.shouldContinueRetryOnFocus + // Create callback to cancel this fetch + this.cancelFetch = () => { + reject(new CancelledError()) + try { + cancelTransport?.() + } catch {} + } - // Determine the retryDelay - const delay = functionalUpdate( - this.config.retryDelay, - this.state.failureCount - ) + // Create callback to continue this fetch + this.continueFetch = () => { + continueLoop?.() + } - // Return a new promise with the retry - return await new Promise((resolve, reject) => { - // Keep track of the retry timeout - this.retryTimeout = setTimeout(async () => { - if (this.cancelled) return reject(this.cancelled) - - try { - const data = await this.tryFetchData(fn, args) - if (this.cancelled) return reject(this.cancelled) - resolve(data) - } catch (error) { - if (this.cancelled) return reject(this.cancelled) - reject(error) + // Filter the query function arguments if needed + const filter = this.config.queryFnParamsFilter + args = filter ? filter(args) : args + + // Create loop function + const run = async () => { + try { + // Execute query + const promiseOrValue = fn(...args) + + // Check if the transport layer support cancellation + if (isCancelable(promiseOrValue)) { + cancelTransport = () => { + promiseOrValue.cancel() } - }, delay) - }) + this.isTransportCancelable = true + } + + // Await data + resolve(await promiseOrValue) + } catch (error) { + // Stop if the fetch is already resolved + if (resolved) { + return + } + + // Do we need to retry the request? + const { failureCount } = this.state + const { retry, retryDelay } = this.config + + const shouldRetry = + retry === true || + failureCount < retry! || + (typeof retry === 'function' && retry(failureCount, error)) + + if (!shouldRetry) { + // We are done if the query does not need to be retried + reject(error) + return + } + + // Increase the failureCount + this.dispatch({ type: ActionType.Failed }) + + // Delay + await sleep(functionalUpdate(retryDelay, failureCount) || 0) + + // Pause retry if the document is not visible + if (!isDocumentVisible()) { + await new Promise(continueResolve => { + continueLoop = continueResolve + }) + } + + // Try again if not resolved yet + if (!resolved) { + run() + } + } } - throw error - } + // Start loop + run() + }) } async fetch(options?: FetchOptions): Promise { + // If we are already fetching, return current promise + if (this.promise) { + return this.promise + } + let queryFn = this.config.queryFn if (!queryFn) { return } - // If we are already fetching, return current promise - if (this.promise) { - return this.promise - } - if (this.config.infinite) { const infiniteConfig = this.config as InfiniteQueryConfig const infiniteData = (this.state.data as unknown) as TResult[] | undefined @@ -565,9 +579,6 @@ export class Query { } this.promise = (async () => { - // If there are any retries pending for this query, kill them - this.cancelled = null - try { // Set to fetching state if not already in it if (!this.state.isFetching) { @@ -575,27 +586,33 @@ export class Query { } // Try to get the data - const data = await this.tryFetchData(queryFn!, this.queryKey) + const data = await this.fetchData(queryFn, this.queryKey) + // Set success state this.setData(data) + // Cleanup delete this.promise + // Return data return data } catch (error) { + // Set error state this.dispatch({ type: ActionType.Error, - cancelled: error === this.cancelled, error, }) - delete this.promise - - if (error !== this.cancelled) { - throw error + // Log error + if (!isCancelledError(error)) { + Console.error(error) } - return + // Cleanup + delete this.promise + + // Propagate error + throw error } })() @@ -691,15 +708,13 @@ export function queryReducer( case ActionType.Error: return { ...state, - failureCount: state.failureCount + 1, + ...getStatusProps(QueryStatus.Error), + error: action.error, isFetched: true, isFetching: false, isStale: true, - ...(!action.cancelled && { - ...getStatusProps(QueryStatus.Error), - error: action.error, - throwInErrorBoundary: true, - }), + failureCount: state.failureCount + 1, + throwInErrorBoundary: true, } case ActionType.SetState: return functionalUpdate(action.updater, state) diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts index 1e92966f2e..267b79bae7 100644 --- a/src/core/queryCache.ts +++ b/src/core/queryCache.ts @@ -1,11 +1,10 @@ import { - isServer, - getQueryArgs, - deepIncludes, - Console, - isObject, Updater, + deepIncludes, functionalUpdate, + getQueryArgs, + isObject, + isServer, } from './utils' import { getDefaultedQueryConfig } from './config' import { Query } from './query' @@ -310,11 +309,10 @@ export class QueryCache { await query.fetch() } return query.state.data - } catch (err) { + } catch (error) { if (options?.throwOnError) { - throw err + throw error } - Console.error(err) return } } diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 49da47b44f..7fed7256ee 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -1,4 +1,4 @@ -import { getStatusProps, isServer, isDocumentVisible, Console } from './utils' +import { getStatusProps, isServer, isDocumentVisible } from './utils' import type { QueryResult, QueryObserverConfig } from './types' import type { Query, QueryState, Action, FetchMoreOptions, RefetchOptions } from './query' @@ -101,10 +101,11 @@ export class QueryObserver { async fetch(): Promise { this.currentQuery.updateConfig(this.config) - return this.currentQuery.fetch().catch(error => { - Console.error(error) + try { + return await this.currentQuery.fetch() + } catch (error) { return undefined - }) + } } private optionalFetch(): void { diff --git a/src/core/setFocusHandler.ts b/src/core/setFocusHandler.ts index 517e39924d..05fecb1c33 100644 --- a/src/core/setFocusHandler.ts +++ b/src/core/setFocusHandler.ts @@ -8,22 +8,17 @@ const focusEvent = 'focus' const onWindowFocus: FocusHandler = () => { if (isDocumentVisible() && isOnline()) { - queryCaches.forEach(queryCache => + queryCaches.forEach(queryCache => { + // Continue any paused queries + queryCache.getQueries(query => { + query.continue() + }) + + // Invalidate queries which should refetch on window focus queryCache - .invalidateQueries(query => { - if (!query.shouldRefetchOnWindowFocus()) { - return false - } - - if (query.shouldContinueRetryOnFocus) { - // delete promise, so refetching will create new one - delete query.promise - } - - return true - }) + .invalidateQueries(query => query.shouldRefetchOnWindowFocus()) .catch(Console.error) - ) + }) } } diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index b7ba712dd1..e5235c7c16 100644 --- a/src/core/tests/queryCache.test.tsx +++ b/src/core/tests/queryCache.test.tsx @@ -1,5 +1,11 @@ -import { sleep, queryKey } from '../../react/tests/utils' +import { + sleep, + queryKey, + mockVisibilityState, + mockConsoleError, +} from '../../react/tests/utils' import { makeQueryCache, queryCache as defaultQueryCache } from '..' +import { isCancelledError, isError } from '../utils' describe('queryCache', () => { test('setQueryData does not crash if query could not be found', () => { @@ -26,6 +32,8 @@ describe('queryCache', () => { // https://github.com/tannerlinsley/react-query/issues/652 test('prefetchQuery should not retry by default', async () => { + const consoleMock = mockConsoleError() + const key = queryKey() await expect( @@ -38,6 +46,8 @@ describe('queryCache', () => { { throwOnError: true } ) ).rejects.toEqual(new Error('error')) + + consoleMock.mockRestore() }) test('prefetchQuery returns the cached data on cache hits', async () => { @@ -69,6 +79,8 @@ describe('queryCache', () => { }) test('prefetchQuery should throw error when throwOnError is true', async () => { + const consoleMock = mockConsoleError() + const key = queryKey() await expect( @@ -83,13 +95,14 @@ describe('queryCache', () => { { throwOnError: true } ) ).rejects.toEqual(new Error('error')) + + consoleMock.mockRestore() }) test('prefetchQuery should return undefined when an error is thrown', async () => { - const key = queryKey() + const consoleMock = mockConsoleError() - const consoleMock = jest.spyOn(console, 'error') - consoleMock.mockImplementation(() => undefined) + const key = queryKey() const result = await defaultQueryCache.prefetchQuery( key, @@ -354,6 +367,7 @@ describe('queryCache', () => { const queryCache = makeQueryCache() const query = queryCache.buildQuery(key) + // @ts-expect-error expect(query.queryCache).toBe(queryCache) }) @@ -369,5 +383,251 @@ describe('queryCache', () => { unsubscribe() }) + + it('should continue retry after focus regain and resolve all promises', async () => { + const key = queryKey() + + const originalVisibilityState = document.visibilityState + + // make page unfocused + mockVisibilityState('hidden') + + let count = 0 + let result + + const promise = defaultQueryCache.prefetchQuery( + key, + async () => { + count++ + + if (count === 3) { + return `data${count}` + } + + throw new Error(`error${count}`) + }, + { + retry: 3, + retryDelay: 1, + } + ) + + promise.then(data => { + result = data + }) + + // Check if we do not have a result + expect(result).toBeUndefined() + + // Check if the query is really paused + await sleep(50) + expect(result).toBeUndefined() + + // Reset visibilityState to original value + mockVisibilityState(originalVisibilityState) + window.dispatchEvent(new FocusEvent('focus')) + + // There should not be a result yet + expect(result).toBeUndefined() + + // By now we should have a value + await sleep(50) + expect(result).toBe('data3') + }) + + it('should throw a CancelledError when a paused query is cancelled', async () => { + const key = queryKey() + + const originalVisibilityState = document.visibilityState + + // make page unfocused + mockVisibilityState('hidden') + + let count = 0 + let result + + const promise = defaultQueryCache.prefetchQuery( + key, + async () => { + count++ + throw new Error(`error${count}`) + }, + { + retry: 3, + retryDelay: 1, + }, + { + throwOnError: true, + } + ) + + promise.catch(data => { + result = data + }) + + const query = defaultQueryCache.getQuery(key)! + + // Check if the query is really paused + await sleep(50) + expect(result).toBeUndefined() + + // Cancel query + query.cancel() + + // Check if the error is set to the cancelled error + await sleep(0) + expect(isCancelledError(result)).toBe(true) + + // Reset visibilityState to original value + mockVisibilityState(originalVisibilityState) + window.dispatchEvent(new FocusEvent('focus')) + }) + + test('query should continue if cancellation is not supported', async () => { + const key = queryKey() + + defaultQueryCache.prefetchQuery(key, async () => { + await sleep(100) + return 'data' + }) + + await sleep(10) + + const query = defaultQueryCache.getQuery(key)! + + // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed + const observer = query.subscribe() + observer.unsubscribe() + + await sleep(100) + + expect(query.state).toMatchObject({ + data: 'data', + isLoading: false, + isFetched: true, + }) + }) + + test('query should not continue if cancellation is supported', async () => { + const key = queryKey() + + const cancel = jest.fn() + + defaultQueryCache.prefetchQuery(key, async () => { + const promise = new Promise((resolve, reject) => { + sleep(100).then(() => resolve('data')) + cancel.mockImplementation(() => { + reject(new Error('Cancelled')) + }) + }) as any + promise.cancel = cancel + return promise + }) + + await sleep(10) + + const query = defaultQueryCache.getQuery(key)! + + // Subscribe and unsubscribe to simulate cancellation because the last observer unsubscribed + const observer = query.subscribe() + observer.unsubscribe() + + await sleep(100) + + expect(cancel).toHaveBeenCalled() + expect(query.state).toMatchObject({ + data: undefined, + isLoading: false, + isFetched: true, + }) + }) + + test('query should not continue if explicitly cancelled', async () => { + const key = queryKey() + + const queryFn = jest.fn() + + queryFn.mockImplementation(async () => { + await sleep(10) + throw new Error() + }) + + let error + + const promise = defaultQueryCache.prefetchQuery( + key, + queryFn, + { + retry: 3, + retryDelay: 10, + }, + { + throwOnError: true, + } + ) + + promise.catch(e => { + error = e + }) + + const query = defaultQueryCache.getQuery(key)! + query.cancel() + + await sleep(100) + + expect(queryFn).toHaveBeenCalledTimes(1) + expect(isCancelledError(error)).toBe(true) + }) + + test('should be able to refetch a cancelled query', async () => { + const key = queryKey() + + const queryFn = jest.fn() + + queryFn.mockImplementation(async () => { + await sleep(50) + return 'data' + }) + + defaultQueryCache.prefetchQuery(key, queryFn) + const query = defaultQueryCache.getQuery(key)! + await sleep(10) + query.cancel() + await sleep(100) + + expect(queryFn).toHaveBeenCalledTimes(1) + expect(isCancelledError(query.state.error)).toBe(true) + const result = await query.fetch() + expect(result).toBe('data') + expect(query.state.error).toBe(null) + expect(queryFn).toHaveBeenCalledTimes(2) + }) + + test('cancelling a resolved query should not have any effect', async () => { + const key = queryKey() + await defaultQueryCache.prefetchQuery(key, async () => 'data') + const query = defaultQueryCache.getQuery(key)! + query.cancel() + await sleep(10) + expect(query.state.data).toBe('data') + }) + + test('cancelling a rejected query should not have any effect', async () => { + const consoleMock = mockConsoleError() + + const key = queryKey() + + await defaultQueryCache.prefetchQuery(key, async () => { + throw new Error('error') + }) + const query = defaultQueryCache.getQuery(key)! + query.cancel() + await sleep(10) + + expect(isError(query.state.error)).toBe(true) + expect(isCancelledError(query.state.error)).toBe(false) + + consoleMock.mockRestore() + }) }) }) diff --git a/src/core/utils.ts b/src/core/utils.ts index d857606f9c..fdc9c9714b 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -16,16 +16,23 @@ export interface ConsoleObject { error: ConsoleFunction } +interface Cancelable { + cancel(): void +} + +export class CancelledError {} + // UTILS let _uid = 0 export const uid = () => _uid++ -export const cancelledError = {} -export const globalStateListeners = [] + export const isServer = typeof window === 'undefined' -export function noop(): void { + +function noop(): void { return void 0 } + export let Console: ConsoleObject = console || { error: noop, warn: noop, @@ -201,6 +208,24 @@ function hasObjectPrototype(o: any): boolean { return Object.prototype.toString.call(o) === '[object Object]' } +export function isCancelable(value: any): value is Cancelable { + return typeof value?.cancel === 'function' +} + +export function isError(value: any): value is Error { + return value instanceof Error +} + +export function isCancelledError(value: any): value is CancelledError { + return value instanceof CancelledError +} + +export function sleep(timeout: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, timeout) + }) +} + export function getStatusProps(status: T) { return { status, diff --git a/src/react/tests/ReactQueryCacheProvider.test.tsx b/src/react/tests/ReactQueryCacheProvider.test.tsx index 3c874ba0ab..9b9b395839 100644 --- a/src/react/tests/ReactQueryCacheProvider.test.tsx +++ b/src/react/tests/ReactQueryCacheProvider.test.tsx @@ -141,7 +141,7 @@ describe('ReactQueryCacheProvider', () => { cache2.clear({ notify: false }) }) - test('when cache changes, previous cache is cleaned', () => { + test('when cache changes, previous cache is cleaned', async () => { const key = queryKey() const caches: QueryCache[] = [] @@ -149,6 +149,7 @@ describe('ReactQueryCacheProvider', () => { function Page() { const queryCache = useQueryCache() + useEffect(() => { caches.push(queryCache) }, [queryCache]) @@ -175,6 +176,8 @@ describe('ReactQueryCacheProvider', () => { const rendered = render() + await waitFor(() => rendered.getByText('test')) + expect(caches).toHaveLength(1) jest.spyOn(caches[0], 'clear') @@ -182,6 +185,9 @@ describe('ReactQueryCacheProvider', () => { expect(caches).toHaveLength(2) expect(caches[0].clear).toHaveBeenCalled() + + await waitFor(() => rendered.getByText('test')) + customCache.clear({ notify: false }) }) diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx index 6d08d06867..0dd3cd0845 100644 --- a/src/react/tests/suspense.test.tsx +++ b/src/react/tests/suspense.test.tsx @@ -2,7 +2,7 @@ import { render, waitFor, fireEvent } from '@testing-library/react' import { ErrorBoundary } from 'react-error-boundary' import * as React from 'react' -import { sleep, queryKey } from './utils' +import { sleep, queryKey, mockConsoleError } from './utils' import { useQuery } from '..' import { queryCache } from '../../core' @@ -133,8 +133,7 @@ describe("useQuery's in Suspense mode", () => { const key = queryKey() let succeed = false - const consoleMock = jest.spyOn(console, 'error') - consoleMock.mockImplementation(() => undefined) + const consoleMock = mockConsoleError() function Page() { useQuery( diff --git a/src/react/tests/useIsFetching.test.tsx b/src/react/tests/useIsFetching.test.tsx index da223a09d2..96f562dd51 100644 --- a/src/react/tests/useIsFetching.test.tsx +++ b/src/react/tests/useIsFetching.test.tsx @@ -1,7 +1,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react' import * as React from 'react' -import { sleep, queryKey } from './utils' +import { sleep, queryKey, mockConsoleError } from './utils' import { useQuery, useIsFetching } from '..' describe('useIsFetching', () => { @@ -42,7 +42,7 @@ describe('useIsFetching', () => { }) it('should not update state while rendering', async () => { - const spy = jest.spyOn(console, 'error') + const consoleMock = mockConsoleError() const key1 = queryKey() const key2 = queryKey() @@ -91,9 +91,9 @@ describe('useIsFetching', () => { render() await waitFor(() => expect(isFetchings).toEqual([1, 1, 2, 1, 0])) - expect(spy).not.toHaveBeenCalled() - expect(spy.mock.calls[0]?.[0] ?? '').not.toMatch('setState') + expect(consoleMock).not.toHaveBeenCalled() + expect(consoleMock.mock.calls[0]?.[0] ?? '').not.toMatch('setState') - spy.mockRestore() + consoleMock.mockRestore() }) }) diff --git a/src/react/tests/useMutation.test.tsx b/src/react/tests/useMutation.test.tsx index e445295889..07e7ed4c66 100644 --- a/src/react/tests/useMutation.test.tsx +++ b/src/react/tests/useMutation.test.tsx @@ -2,6 +2,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react' import * as React from 'react' import { useMutation } from '..' +import { mockConsoleError } from './utils' describe('useMutation', () => { it('should be able to reset `data`', async () => { @@ -37,8 +38,7 @@ describe('useMutation', () => { }) it('should be able to reset `error`', async () => { - const consoleMock = jest.spyOn(console, 'error') - consoleMock.mockImplementation(() => undefined) + const consoleMock = mockConsoleError() function Page() { const [mutate, mutationResult] = useMutation( @@ -128,8 +128,8 @@ describe('useMutation', () => { }) it('should be able to call `onError` and `onSettled` after each failed mutate', async () => { - const consoleMock = jest.spyOn(console, 'error') - consoleMock.mockImplementation(() => undefined) + const consoleMock = mockConsoleError() + const onErrorMock = jest.fn() const onSettledMock = jest.fn() let count = 0 @@ -189,5 +189,7 @@ describe('useMutation', () => { ) expect(getByTestId('title').textContent).toBe('3') + + consoleMock.mockRestore() }) }) diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index ba3f2952a5..78a4e94e74 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -1,7 +1,13 @@ import { render, act, waitFor, fireEvent } from '@testing-library/react' import * as React from 'react' -import { sleep, expectType, queryKey } from './utils' +import { + sleep, + expectType, + queryKey, + mockVisibilityState, + mockConsoleError, +} from './utils' import { useQuery } from '..' import { queryCache, QueryResult } from '../../core' @@ -159,8 +165,7 @@ describe('useQuery', () => { it('should return the correct states for an unsuccessful query', async () => { const key = queryKey() - const consoleMock = jest.spyOn(console, 'error') - consoleMock.mockImplementation(() => undefined) + const consoleMock = mockConsoleError() const states: QueryResult[] = [] @@ -249,6 +254,8 @@ describe('useQuery', () => { status: 'error', updatedAt: expect.any(Number), }) + + consoleMock.mockRestore() }) // https://github.com/tannerlinsley/react-query/issues/896 @@ -582,8 +589,7 @@ describe('useQuery', () => { it('should set status to error if queryFn throws', async () => { const key = queryKey() - const consoleMock = jest.spyOn(console, 'error') - consoleMock.mockImplementation(() => undefined) + const consoleMock = mockConsoleError() function Page() { const { status, error } = useQuery( @@ -612,8 +618,7 @@ describe('useQuery', () => { it('should retry specified number of times', async () => { const key = queryKey() - const consoleMock = jest.spyOn(console, 'error') - consoleMock.mockImplementation(() => undefined) + const consoleMock = mockConsoleError() const queryFn = jest.fn() queryFn.mockImplementation(() => { @@ -649,8 +654,7 @@ describe('useQuery', () => { it('should not retry if retry function `false`', async () => { const key = queryKey() - const consoleMock = jest.spyOn(console, 'error') - consoleMock.mockImplementation(() => undefined) + const consoleMock = mockConsoleError() const queryFn = jest.fn() @@ -693,69 +697,35 @@ describe('useQuery', () => { consoleMock.mockRestore() }) - it('should garbage collect queries without data immediately', async () => { - const key = queryKey() - - function Page() { - type Filters = { filter: string } - const [filter, setFilter] = React.useState('') - const filters: Filters = { filter } - const { data } = useQuery( - [key, filters], - async (_key: string, { filter }: Filters) => { - await sleep(10) - return `todo ${filter}` - } - ) - - return ( -
-
{data}
- -
- ) - } - - const rendered = render() - - await waitFor(() => rendered.getByText('update')) - - fireEvent.click(rendered.getByText('update')) - fireEvent.click(rendered.getByText('update')) - fireEvent.click(rendered.getByText('update')) - fireEvent.click(rendered.getByText('update')) - - expect(queryCache.getQuery([key, { filter: 'a' }])).not.toBeUndefined() - - await waitFor(() => rendered.getByText('todo aaaa')) - - expect(queryCache.getQuery([key, { filter: 'a' }])).toBeUndefined() - }) - // See https://github.com/tannerlinsley/react-query/issues/160 it('should continue retry after focus regain', async () => { const key = queryKey() - const originalVisibilityState = document.visibilityState + const consoleMock = mockConsoleError() - function mockVisibilityState(value: string) { - Object.defineProperty(document, 'visibilityState', { - value, - configurable: true, - }) - } + const originalVisibilityState = document.visibilityState // make page unfocused mockVisibilityState('hidden') + let count = 0 + function Page() { - const query = useQuery(key, () => Promise.reject('fetching error'), { - retry: 3, - retryDelay: 1, - }) + const query = useQuery( + key, + () => { + count++ + return Promise.reject(`fetching error ${count}`) + }, + { + retry: 3, + retryDelay: 1, + } + ) return (
+
error {String(query.error)}
status {query.status}
failureCount {query.failureCount}
@@ -764,8 +734,14 @@ describe('useQuery', () => { const rendered = render() + // The query should display the first error result await waitFor(() => rendered.getByText('failureCount 1')) await waitFor(() => rendered.getByText('status loading')) + await waitFor(() => rendered.getByText('error null')) + + // Check if the query really paused + await sleep(10) + await waitFor(() => rendered.getByText('failureCount 1')) act(() => { // reset visibilityState to original value @@ -773,8 +749,19 @@ describe('useQuery', () => { window.dispatchEvent(new FocusEvent('focus')) }) + // Wait for the final result await waitFor(() => rendered.getByText('failureCount 4')) await waitFor(() => rendered.getByText('status error')) + await waitFor(() => rendered.getByText('error fetching error 4')) + + // Check if the query really stopped + await sleep(10) + await waitFor(() => rendered.getByText('failureCount 4')) + + // Check if the error has been logged in the console + expect(consoleMock).toHaveBeenCalledWith('fetching error 4') + + consoleMock.mockRestore() }) // See https://github.com/tannerlinsley/react-query/issues/195 @@ -838,8 +825,7 @@ describe('useQuery', () => { it('should reset failureCount on successful fetch', async () => { const key = queryKey() - const consoleMock = jest.spyOn(console, 'error') - consoleMock.mockImplementation(() => undefined) + const consoleMock = mockConsoleError() function Page() { let counter = 0 @@ -1160,8 +1146,7 @@ describe('useQuery', () => { }) it('should error when using functions as query keys', () => { - const consoleMock = jest.spyOn(console, 'error') - consoleMock.mockImplementation(() => undefined) + const consoleMock = mockConsoleError() function Page() { useQuery( diff --git a/src/react/tests/utils.tsx b/src/react/tests/utils.tsx index ce5380f9c0..481b1836f0 100644 --- a/src/react/tests/utils.tsx +++ b/src/react/tests/utils.tsx @@ -1,5 +1,18 @@ let queryKeyCount = 0 +export function mockVisibilityState(value: string) { + Object.defineProperty(document, 'visibilityState', { + value, + configurable: true, + }) +} + +export function mockConsoleError() { + const consoleMock = jest.spyOn(console, 'error') + consoleMock.mockImplementation(() => undefined) + return consoleMock +} + export function queryKey(): string { queryKeyCount++ return `query_${queryKeyCount}`