diff --git a/rollup.config.js b/rollup.config.js index 78de37919b..98f750f7f4 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -7,11 +7,12 @@ import commonJS from 'rollup-plugin-commonjs' import visualizer from 'rollup-plugin-visualizer' import replace from '@rollup/plugin-replace' -const external = ['react'] +const external = ['react', 'react-dom'] const hydrationExternal = [...external, 'react-query'] const globals = { react: 'React', + 'react-dom': 'ReactDOM', } const hydrationGlobals = { ...globals, diff --git a/src/core/index.ts b/src/core/index.ts index a4636e68fc..49b4666c1d 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,7 +1,13 @@ export { queryCache, queryCaches, makeQueryCache } from './queryCache' export { setFocusHandler } from './setFocusHandler' export { setOnlineHandler } from './setOnlineHandler' -export { CancelledError, isCancelledError, isError, setConsole } from './utils' +export { + CancelledError, + isCancelledError, + isError, + setConsole, + setBatchedUpdates, +} from './utils' // Types export * from './types' diff --git a/src/core/query.ts b/src/core/query.ts index 54483ac29f..f223e53fb0 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -131,11 +131,13 @@ export class Query { private dispatch(action: Action): void { this.state = queryReducer(this.state, action) - this.observers.forEach(observer => { - observer.onQueryUpdate(action) - }) + this.queryCache.batchNotifications(() => { + this.observers.forEach(observer => { + observer.onQueryUpdate(action) + }) - this.queryCache.notifyGlobalListeners(this) + this.queryCache.notifyGlobalListeners(this) + }) } private scheduleGc(): void { diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts index ed932800b2..d0ca2a6d6d 100644 --- a/src/core/queryCache.ts +++ b/src/core/queryCache.ts @@ -2,11 +2,13 @@ import { Updater, deepIncludes, functionalUpdate, + getBatchedUpdates, getQueryArgs, isDocumentVisible, - isPlainObject, isOnline, + isPlainObject, isServer, + scheduleMicrotask, } from './utils' import { getResolvedQueryConfig } from './config' import { Query } from './query' @@ -66,6 +68,8 @@ type QueryCacheListener = ( query?: Query ) => void +type NotifyCallback = () => void + // CLASS export class QueryCache { @@ -75,6 +79,8 @@ export class QueryCache { private globalListeners: QueryCacheListener[] private queries: QueryHashMap private queriesArray: Query[] + private notifyQueue: NotifyCallback[] + private notifyTransactions: number constructor(config?: QueryCacheConfig) { this.config = config || {} @@ -82,6 +88,42 @@ export class QueryCache { this.queries = {} this.queriesArray = [] this.isFetching = 0 + this.notifyQueue = [] + this.notifyTransactions = 0 + } + + batchNotifications(callback: () => void): void { + this.notifyTransactions++ + callback() + this.notifyTransactions-- + if (!this.notifyTransactions) { + this.executeNotifications() + } + } + + executeNotifications(): void { + if (this.notifyQueue.length) { + scheduleMicrotask(() => { + const batchedUpdates = getBatchedUpdates() + batchedUpdates(() => { + const queue = this.notifyQueue + this.notifyQueue = [] + queue.forEach(notify => { + notify() + }) + }) + }) + } + } + + scheduleNotification(notify: NotifyCallback): void { + if (this.notifyTransactions) { + this.notifyQueue.push(notify) + } else { + scheduleMicrotask(() => { + notify() + }) + } } notifyGlobalListeners(query?: Query) { @@ -91,7 +133,9 @@ export class QueryCache { ) this.globalListeners.forEach(listener => { - listener(this, query) + this.scheduleNotification(() => { + listener(this, query) + }) }) } @@ -195,17 +239,18 @@ export class QueryCache { options || {} try { - await Promise.all( - this.getQueries(predicate, options).map(query => { - const enabled = query.isEnabled() + const promises: Promise[] = [] + this.batchNotifications(() => { + this.getQueries(predicate, options).forEach(query => { + const enabled = query.isEnabled() if ((enabled && refetchActive) || (!enabled && refetchInactive)) { - return query.fetch() + promises.push(query.fetch()) } - - return undefined }) - ) + }) + + await Promise.all(promises) } catch (err) { if (throwOnError) { throw err @@ -363,8 +408,10 @@ export function makeQueryCache(config?: QueryCacheConfig) { export function onVisibilityOrOnlineChange(type: 'focus' | 'online') { if (isDocumentVisible() && isOnline()) { queryCaches.forEach(queryCache => { - queryCache.getQueries().forEach(query => { - query.onInteraction(type) + queryCache.batchNotifications(() => { + queryCache.getQueries().forEach(query => { + query.onInteraction(type) + }) }) }) } diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 0b7cc76f97..75414a9c4d 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -139,7 +139,9 @@ export class QueryObserver { } private notify(): void { - this.listener?.(this.currentResult) + this.config.queryCache.scheduleNotification(() => { + this.listener?.(this.currentResult) + }) } private updateStaleTimeout(): void { diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index 4cb3d5018b..27f8cbc467 100644 --- a/src/core/tests/queryCache.test.tsx +++ b/src/core/tests/queryCache.test.tsx @@ -5,7 +5,7 @@ import { mockConsoleError, mockNavigatorOnLine, } from '../../react/tests/utils' -import { makeQueryCache, queryCache as defaultQueryCache } from '..' +import { makeQueryCache, queryCache as defaultQueryCache } from '../..' import { isCancelledError, isError } from '../utils' describe('queryCache', () => { @@ -348,7 +348,7 @@ describe('queryCache', () => { expect(query.queryCache).toBe(queryCache) }) - test('notifyGlobalListeners passes the same instance', () => { + test('notifyGlobalListeners passes the same instance', async () => { const key = queryKey() const queryCache = makeQueryCache() @@ -356,6 +356,7 @@ describe('queryCache', () => { const unsubscribe = queryCache.subscribe(subscriber) const query = queryCache.buildQuery(key) query.setData('foo') + await sleep(1) expect(subscriber).toHaveBeenCalledWith(queryCache, query) unsubscribe() diff --git a/src/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx index aca579458f..88068193e1 100644 --- a/src/core/tests/utils.test.tsx +++ b/src/core/tests/utils.test.tsx @@ -4,7 +4,7 @@ import { deepIncludes, isPlainObject, } from '../utils' -import { setConsole, queryCache } from '..' +import { setConsole, queryCache } from '../..' import { queryKey } from '../../react/tests/utils' describe('core/utils', () => { diff --git a/src/core/utils.ts b/src/core/utils.ts index a1a36a3e02..725e0f6ba2 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -249,3 +249,34 @@ export function createSetHandler(fn: () => void) { removePreviousHandler = callback(fn) } } + +/** + * Schedules a microtask. + * This can be useful to schedule state updates after rendering. + */ +export function scheduleMicrotask(callback: () => void): void { + Promise.resolve() + .then(callback) + .catch(error => + setTimeout(() => { + throw error + }) + ) +} + +type BatchUpdateFunction = (callback: () => void) => void + +// Default to a dummy "batch" implementation that just runs the callback +let batchedUpdates: BatchUpdateFunction = (callback: () => void) => { + callback() +} + +// Allow injecting another batching function later +export function setBatchedUpdates(fn: BatchUpdateFunction) { + batchedUpdates = fn +} + +// Supply a getter just to skip dealing with ESM bindings +export function getBatchedUpdates(): BatchUpdateFunction { + return batchedUpdates +} diff --git a/src/index.ts b/src/index.ts index 24c75db981..b6024ee8ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,6 @@ +import { setBatchedUpdates } from './core' +import { unstable_batchedUpdates } from './react/reactBatchedUpdates' +setBatchedUpdates(unstable_batchedUpdates) + export * from './core/index' export * from './react/index' diff --git a/src/react/reactBatchedUpdates.native.ts b/src/react/reactBatchedUpdates.native.ts new file mode 100644 index 0000000000..ae1adf81d5 --- /dev/null +++ b/src/react/reactBatchedUpdates.native.ts @@ -0,0 +1,3 @@ +// @ts-ignore +import { unstable_batchedUpdates } from 'react-native' +export { unstable_batchedUpdates } diff --git a/src/react/reactBatchedUpdates.ts b/src/react/reactBatchedUpdates.ts new file mode 100644 index 0000000000..baa3d01ee3 --- /dev/null +++ b/src/react/reactBatchedUpdates.ts @@ -0,0 +1,2 @@ +import ReactDOM from 'react-dom' +export const unstable_batchedUpdates = ReactDOM.unstable_batchedUpdates diff --git a/src/react/tests/ReactQueryCacheProvider.test.tsx b/src/react/tests/ReactQueryCacheProvider.test.tsx index 20e6c8e313..3eef92a29a 100644 --- a/src/react/tests/ReactQueryCacheProvider.test.tsx +++ b/src/react/tests/ReactQueryCacheProvider.test.tsx @@ -2,8 +2,14 @@ import React, { useEffect } from 'react' import { render, waitFor } from '@testing-library/react' import { sleep, queryKey } from './utils' -import { ReactQueryCacheProvider, useQuery, useQueryCache } from '..' -import { makeQueryCache, queryCache, QueryCache } from '../../core' +import { + ReactQueryCacheProvider, + useQuery, + useQueryCache, + makeQueryCache, + queryCache, + QueryCache, +} from '../..' describe('ReactQueryCacheProvider', () => { test('when not used, falls back to global cache', async () => { diff --git a/src/react/tests/ReactQueryConfigProvider.test.tsx b/src/react/tests/ReactQueryConfigProvider.test.tsx index b74c6b6f40..72c1a398bf 100644 --- a/src/react/tests/ReactQueryConfigProvider.test.tsx +++ b/src/react/tests/ReactQueryConfigProvider.test.tsx @@ -2,8 +2,7 @@ import React, { useState } from 'react' import { act, fireEvent, render, waitFor } from '@testing-library/react' import { sleep, queryKey } from './utils' -import { ReactQueryConfigProvider, useQuery } from '..' -import { queryCache } from '../../core' +import { ReactQueryConfigProvider, useQuery, queryCache } from '../..' describe('ReactQueryConfigProvider', () => { // // See https://github.com/tannerlinsley/react-query/issues/105 diff --git a/src/react/tests/ssr.test.tsx b/src/react/tests/ssr.test.tsx index 5178d17241..f3201fdb49 100644 --- a/src/react/tests/ssr.test.tsx +++ b/src/react/tests/ssr.test.tsx @@ -7,8 +7,13 @@ import React from 'react' import { renderToString } from 'react-dom/server' import { sleep, queryKey } from './utils' -import { usePaginatedQuery, ReactQueryCacheProvider, useQuery } from '..' -import { queryCache, makeQueryCache } from '../../core' +import { + usePaginatedQuery, + ReactQueryCacheProvider, + useQuery, + queryCache, + makeQueryCache, +} from '../..' describe('Server Side Rendering', () => { // A frozen cache does not cache any data. This is the default diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx index 0dd3cd0845..4ad2a96428 100644 --- a/src/react/tests/suspense.test.tsx +++ b/src/react/tests/suspense.test.tsx @@ -3,8 +3,7 @@ import { ErrorBoundary } from 'react-error-boundary' import * as React from 'react' import { sleep, queryKey, mockConsoleError } from './utils' -import { useQuery } from '..' -import { queryCache } from '../../core' +import { useQuery, queryCache } from '../..' describe("useQuery's in Suspense mode", () => { it('should not call the queryFn twice when used in Suspense mode', async () => { diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx index 86deb29d08..b04a05c53c 100644 --- a/src/react/tests/useInfiniteQuery.test.tsx +++ b/src/react/tests/useInfiniteQuery.test.tsx @@ -2,8 +2,7 @@ import { render, waitFor, fireEvent } from '@testing-library/react' import * as React from 'react' import { sleep, queryKey } from './utils' -import { useInfiniteQuery, useQueryCache } from '..' -import { InfiniteQueryResult } from '../../core' +import { useInfiniteQuery, useQueryCache, InfiniteQueryResult } from '../..' interface Result { items: number[] diff --git a/src/react/tests/useIsFetching.test.tsx b/src/react/tests/useIsFetching.test.tsx index 96f562dd51..2e7fb0a905 100644 --- a/src/react/tests/useIsFetching.test.tsx +++ b/src/react/tests/useIsFetching.test.tsx @@ -2,7 +2,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react' import * as React from 'react' import { sleep, queryKey, mockConsoleError } from './utils' -import { useQuery, useIsFetching } from '..' +import { useQuery, useIsFetching } from '../..' describe('useIsFetching', () => { // See https://github.com/tannerlinsley/react-query/issues/105 diff --git a/src/react/tests/useMutation.test.tsx b/src/react/tests/useMutation.test.tsx index 8d1e48318e..b4fb38d24f 100644 --- a/src/react/tests/useMutation.test.tsx +++ b/src/react/tests/useMutation.test.tsx @@ -1,7 +1,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react' import * as React from 'react' -import { useMutation } from '..' +import { useMutation } from '../..' import { mockConsoleError } from './utils' describe('useMutation', () => { diff --git a/src/react/tests/usePaginatedQuery.test.tsx b/src/react/tests/usePaginatedQuery.test.tsx index c12f11637a..a95773ebb6 100644 --- a/src/react/tests/usePaginatedQuery.test.tsx +++ b/src/react/tests/usePaginatedQuery.test.tsx @@ -2,8 +2,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react' import * as React from 'react' import { sleep, queryKey } from './utils' -import { usePaginatedQuery } from '..' -import { PaginatedQueryResult } from '../../core' +import { usePaginatedQuery, PaginatedQueryResult } from '../..' describe('usePaginatedQuery', () => { it('should return the correct states for a successful query', async () => { diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 684ac832ba..6f327c0ee5 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -9,8 +9,7 @@ import { mockConsoleError, waitForMs, } from './utils' -import { useQuery } from '..' -import { queryCache, QueryResult } from '../../core' +import { useQuery, queryCache, QueryResult } from '../..' describe('useQuery', () => { it('should return the correct types', () => { @@ -820,13 +819,10 @@ describe('useQuery', () => { render() - await waitFor(() => - expect(states).toMatchObject([ - { isStale: true }, - { isStale: false }, - { isStale: true }, - ]) - ) + await waitFor(() => expect(states.length).toBe(3)) + expect(states[0]).toMatchObject({ isStale: true }) + expect(states[1]).toMatchObject({ isStale: false }) + expect(states[2]).toMatchObject({ isStale: true }) }) it('should not re-render when a query status changes and notifyOnStatusChange is false', async () => { @@ -930,6 +926,31 @@ describe('useQuery', () => { expect(queryCache.getQuery(key)!.config.queryFn).toBe(queryFn1) }) + it('should batch re-renders', async () => { + const key = queryKey() + + let renders = 0 + + const queryFn = async () => { + await sleep(10) + return 'data' + } + + function Page() { + useQuery(key, queryFn) + useQuery(key, queryFn) + renders++ + return null + } + + render() + + await waitForMs(20) + + // Should be 2 instead of 3 + expect(renders).toBe(2) + }) + // See https://github.com/tannerlinsley/react-query/issues/170 it('should start with status idle if enabled is false', async () => { const key1 = queryKey() @@ -1145,14 +1166,11 @@ describe('useQuery', () => { render() await waitFor(() => expect(states.length).toBe(5)) - - expect(states).toMatchObject([ - { data: { count: 0 } }, - { data: { count: 0 } }, - { data: { count: 1 } }, - { data: { count: 1 } }, - { data: { count: 10 } }, - ]) + expect(states[0]).toMatchObject({ data: { count: 0 } }) + expect(states[1]).toMatchObject({ data: { count: 0 } }) + expect(states[2]).toMatchObject({ data: { count: 1 } }) + expect(states[3]).toMatchObject({ data: { count: 1 } }) + expect(states[4]).toMatchObject({ data: { count: 10 } }) }) it('should retry specified number of times', async () => { diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts index 6509f8efd0..3c2d204d44 100644 --- a/src/react/useBaseQuery.ts +++ b/src/react/useBaseQuery.ts @@ -1,6 +1,6 @@ import React from 'react' -import { useRerenderer } from './utils' +import { useIsMounted } from './utils' import { getResolvedQueryConfig } from '../core/config' import { QueryObserver } from '../core/queryObserver' import { QueryResultBase, QueryConfig, QueryKey } from '../core/types' @@ -11,7 +11,8 @@ export function useBaseQuery( queryKey: QueryKey, config?: QueryConfig ): QueryResultBase { - const rerender = useRerenderer() + const [, rerender] = React.useReducer(c => c + 1, 0) + const isMounted = useIsMounted() const cache = useQueryCache() const contextConfig = useContextConfig() @@ -33,9 +34,11 @@ export function useBaseQuery( React.useEffect( () => observer.subscribe(() => { - rerender() + if (isMounted()) { + rerender() + } }), - [observer, rerender] + [isMounted, observer, rerender] ) // Update config diff --git a/src/react/useIsFetching.ts b/src/react/useIsFetching.ts index e8acfa3352..2f3658f0b0 100644 --- a/src/react/useIsFetching.ts +++ b/src/react/useIsFetching.ts @@ -1,19 +1,21 @@ import React from 'react' import { useQueryCache } from './ReactQueryCacheProvider' -import { useSafeState } from './utils' +import { useIsMounted } from './utils' export function useIsFetching(): number { + const isMounted = useIsMounted() const queryCache = useQueryCache() - - const [isFetching, setIsFetching] = useSafeState(queryCache.isFetching) + const [isFetching, setIsFetching] = React.useState(queryCache.isFetching) React.useEffect( () => queryCache.subscribe(() => { - setIsFetching(queryCache.isFetching) + if (isMounted()) { + setIsFetching(queryCache.isFetching) + } }), - [queryCache, setIsFetching] + [queryCache, setIsFetching, isMounted] ) return isFetching diff --git a/src/react/utils.ts b/src/react/utils.ts index 034b03f4cb..ff279c30d5 100644 --- a/src/react/utils.ts +++ b/src/react/utils.ts @@ -2,7 +2,7 @@ import React from 'react' import { isServer } from '../core/utils' -function useIsMounted(): () => boolean { +export function useIsMounted(): () => boolean { const mountedRef = React.useRef(false) const isMounted = React.useCallback(() => mountedRef.current, []) @@ -27,47 +27,3 @@ export function useMountedCallback(callback: T): T { [callback, isMounted] ) as any) as T } - -/** - * This hook is a safe useState version which schedules state updates in microtasks - * to prevent updating a component state while React is rendering different components - * or when the component is not mounted anymore. - */ -export function useSafeState( - initialState: S | (() => S) -): [S, React.Dispatch>] { - const isMounted = useIsMounted() - const [state, setState] = React.useState(initialState) - - const safeSetState = React.useCallback( - (value: React.SetStateAction) => { - scheduleMicrotask(() => { - if (isMounted()) { - setState(value) - } - }) - }, - [isMounted] - ) - - return [state, safeSetState] -} - -export function useRerenderer() { - const [, setState] = useSafeState({}) - return React.useCallback(() => setState({}), [setState]) -} - -/** - * Schedules a microtask. - * This can be useful to schedule state updates after rendering. - */ -function scheduleMicrotask(callback: () => void): void { - Promise.resolve() - .then(callback) - .catch(error => - setTimeout(() => { - throw error - }) - ) -}