diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md index a60331b915..a6df6ef61b 100644 --- a/docs/src/pages/guides/migrating-to-react-query-4.md +++ b/docs/src/pages/guides/migrating-to-react-query-4.md @@ -94,6 +94,27 @@ For the same reason, those have also been combined: This flag defaults to `active` because `refetchActive` defaulted to `true`. This means we also need a way to tell `invalidateQueries` to not refetch at all, which is why a fourth option (`none`) is also allowed here. +### Streamlined NotifyEvents + +Subscribing manually to the `QueryCache` has always given you a `QueryCacheNotifyEvent`, but this was not true for the `MutationCache`. We have streamlined the behavior and also adapted event names accordingly. + +#### QueryCacheNotifyEvent + +```diff +- type: 'queryAdded' ++ type: 'added' +- type: 'queryRemoved' ++ type: 'removed' +- type: 'queryUpdated' ++ type: 'updated' +``` + +#### MutationCacheNotifyEvent + +The `MutationCacheNotifyEvent` uses the same types as the `QueryCacheNotifyEvent`. + +> Note: This is only relevant if you manually subscribe to the caches via `queryCache.subscribe` or `mutationCache.subscribe` + ### The `src/react` directory was renamed to `src/reactjs` Previously, react-query had a directory named `react` which imported from the `react` module. This could cause problems with some Jest configurations, resulting in errors when running tests like: @@ -110,3 +131,9 @@ If you were importing anything from `'react-query/react'` directly in your proje - import { QueryClientProvider } from 'react-query/react'; + import { QueryClientProvider } from 'react-query/reactjs'; ``` + +## New Features 🚀 + +### Mutation Cache Garbage Collection + +Mutations can now also be garbage collected automatically, just like queries. The default `cacheTime` for mutations is also set to 5 minutes. diff --git a/docs/src/pages/reference/MutationCache.md b/docs/src/pages/reference/MutationCache.md index 8bb76b4745..2b4d966cc3 100644 --- a/docs/src/pages/reference/MutationCache.md +++ b/docs/src/pages/reference/MutationCache.md @@ -60,8 +60,8 @@ const mutations = mutationCache.getAll() The `subscribe` method can be used to subscribe to the mutation cache as a whole and be informed of safe/known updates to the cache like mutation states changing or mutations being updated, added or removed. ```js -const callback = mutation => { - console.log(mutation) +const callback = event => { + console.log(event.type, event.mutation) } const unsubscribe = mutationCache.subscribe(callback) @@ -69,7 +69,7 @@ const unsubscribe = mutationCache.subscribe(callback) **Options** -- `callback: (mutation?: Mutation) => void` +- `callback: (mutation?: MutationCacheNotifyEvent) => void` - This function will be called with the mutation cache any time it is updated. **Returns** diff --git a/docs/src/pages/reference/useMutation.md b/docs/src/pages/reference/useMutation.md index e8b2e9cdcb..6f4571dcbb 100644 --- a/docs/src/pages/reference/useMutation.md +++ b/docs/src/pages/reference/useMutation.md @@ -17,13 +17,14 @@ const { reset, status, } = useMutation(mutationFn, { + cacheTime, mutationKey, onError, onMutate, onSettled, onSuccess, useErrorBoundary, - meta, + meta }) mutate(variables, { @@ -39,6 +40,9 @@ mutate(variables, { - **Required** - A function that performs an asynchronous task and returns a promise. - `variables` is an object that `mutate` will pass to your `mutationFn` +- `cacheTime: number | Infinity` + - The time in milliseconds that unused/inactive cache data remains in memory. When a mutation's cache becomes unused or inactive, that cache data will be garbage collected after this duration. When different cache times are specified, the longest one will be used. + - If set to `Infinity`, will disable garbage collection - `mutationKey: string` - Optional - A mutation key can be set to inherit defaults set with `queryClient.setMutationDefaults` or to identify the mutation in the devtools. diff --git a/src/broadcastQueryClient-experimental/index.ts b/src/broadcastQueryClient-experimental/index.ts index 9f6181553a..6a36d1c339 100644 --- a/src/broadcastQueryClient-experimental/index.ts +++ b/src/broadcastQueryClient-experimental/index.ts @@ -33,20 +33,20 @@ export function broadcastQueryClient({ } = queryEvent if ( - queryEvent.type === 'queryUpdated' && + queryEvent.type === 'updated' && queryEvent.action?.type === 'success' ) { channel.postMessage({ - type: 'queryUpdated', + type: 'updated', queryHash, queryKey, state, }) } - if (queryEvent.type === 'queryRemoved') { + if (queryEvent.type === 'removed') { channel.postMessage({ - type: 'queryRemoved', + type: 'removed', queryHash, queryKey, }) @@ -61,7 +61,7 @@ export function broadcastQueryClient({ tx(() => { const { type, queryHash, queryKey, state } = action - if (type === 'queryUpdated') { + if (type === 'updated') { const query = queryCache.get(queryHash) if (query) { @@ -77,7 +77,7 @@ export function broadcastQueryClient({ }, state ) - } else if (type === 'queryRemoved') { + } else if (type === 'removed') { const query = queryCache.get(queryHash) if (query) { diff --git a/src/core/mutation.ts b/src/core/mutation.ts index 3846e60ba4..f44f9b50f6 100644 --- a/src/core/mutation.ts +++ b/src/core/mutation.ts @@ -3,6 +3,7 @@ import type { MutationCache } from './mutationCache' import type { MutationObserver } from './mutationObserver' import { getLogger } from './logger' import { notifyManager } from './notifyManager' +import { Removable } from './removable' import { Retryer } from './retryer' import { noop } from './utils' @@ -81,7 +82,7 @@ export class Mutation< TError = unknown, TVariables = void, TContext = unknown -> { +> extends Removable { state: MutationState options: MutationOptions mutationId: number @@ -92,6 +93,8 @@ export class Mutation< private retryer?: Retryer constructor(config: MutationConfig) { + super() + this.options = { ...config.defaultOptions, ...config.options, @@ -101,6 +104,9 @@ export class Mutation< this.observers = [] this.state = config.state || getDefaultState() this.meta = config.meta + + this.updateCacheTime(this.options.cacheTime) + this.scheduleGc() } setState(state: MutationState): void { @@ -110,11 +116,42 @@ export class Mutation< addObserver(observer: MutationObserver): void { if (this.observers.indexOf(observer) === -1) { this.observers.push(observer) + + // Stop the mutation from being garbage collected + this.clearGcTimeout() + + this.mutationCache.notify({ + type: 'observerAdded', + mutation: this, + observer, + }) } } removeObserver(observer: MutationObserver): void { this.observers = this.observers.filter(x => x !== observer) + + if (this.cacheTime) { + this.scheduleGc() + } else { + this.mutationCache.remove(this) + } + + this.mutationCache.notify({ + type: 'observerRemoved', + mutation: this, + observer, + }) + } + + protected optionalRemove() { + if (!this.observers.length) { + if (this.state.status === 'loading') { + this.scheduleGc() + } else { + this.mutationCache.remove(this) + } + } } cancel(): Promise { @@ -252,7 +289,11 @@ export class Mutation< this.observers.forEach(observer => { observer.onMutationUpdate(action) }) - this.mutationCache.notify(this) + this.mutationCache.notify({ + mutation: this, + type: 'updated', + action, + }) }) } } diff --git a/src/core/mutationCache.ts b/src/core/mutationCache.ts index eef98e3ec4..7a2741c922 100644 --- a/src/core/mutationCache.ts +++ b/src/core/mutationCache.ts @@ -1,9 +1,10 @@ +import { MutationObserver } from './mutationObserver' import type { MutationOptions } from './types' import type { QueryClient } from './queryClient' import { notifyManager } from './notifyManager' -import { Mutation, MutationState } from './mutation' +import { Action, Mutation, MutationState } from './mutation' import { matchMutation, MutationFilters, noop } from './utils' -import { Subscribable } from './subscribable' +import { Notifiable } from './notifiable' // TYPES @@ -12,21 +13,53 @@ interface MutationCacheConfig { error: unknown, variables: unknown, context: unknown, - mutation: Mutation + mutation: Mutation ) => void onSuccess?: ( data: unknown, variables: unknown, context: unknown, - mutation: Mutation + mutation: Mutation ) => void } -type MutationCacheListener = (mutation?: Mutation) => void +interface NotifyEventMutationAdded { + type: 'added' + mutation: Mutation +} +interface NotifyEventMutationRemoved { + type: 'removed' + mutation: Mutation +} + +interface NotifyEventMutationObserverAdded { + type: 'observerAdded' + mutation: Mutation + observer: MutationObserver +} + +interface NotifyEventMutationObserverRemoved { + type: 'observerRemoved' + mutation: Mutation + observer: MutationObserver +} + +interface NotifyEventMutationUpdated { + type: 'updated' + mutation: Mutation + action: Action +} + +type MutationCacheNotifyEvent = + | NotifyEventMutationAdded + | NotifyEventMutationRemoved + | NotifyEventMutationObserverAdded + | NotifyEventMutationObserverRemoved + | NotifyEventMutationUpdated // CLASS -export class MutationCache extends Subscribable { +export class MutationCache extends Notifiable { config: MutationCacheConfig private mutations: Mutation[] @@ -62,13 +95,13 @@ export class MutationCache extends Subscribable { add(mutation: Mutation): void { this.mutations.push(mutation) - this.notify(mutation) + this.notify({ type: 'added', mutation }) } remove(mutation: Mutation): void { this.mutations = this.mutations.filter(x => x !== mutation) mutation.cancel() - this.notify(mutation) + this.notify({ type: 'removed', mutation }) } clear(): void { @@ -97,14 +130,6 @@ export class MutationCache extends Subscribable { return this.mutations.filter(mutation => matchMutation(filters, mutation)) } - notify(mutation?: Mutation) { - notifyManager.batch(() => { - this.listeners.forEach(listener => { - listener(mutation) - }) - }) - } - onFocus(): void { this.resumePausedMutations() } diff --git a/src/core/notifiable.ts b/src/core/notifiable.ts new file mode 100644 index 0000000000..3d2bcd857f --- /dev/null +++ b/src/core/notifiable.ts @@ -0,0 +1,12 @@ +import { Subscribable } from './subscribable' +import { notifyManager } from '../core/notifyManager' + +export class Notifiable extends Subscribable<(event: TEvent) => void> { + notify(event: TEvent) { + notifyManager.batch(() => { + this.listeners.forEach(listener => { + listener(event) + }) + }) + } +} diff --git a/src/core/query.ts b/src/core/query.ts index a466b137bc..643825e381 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -2,7 +2,6 @@ import { getAbortController, Updater, functionalUpdate, - isValidTimeout, noop, replaceEqualDeep, timeUntilStale, @@ -24,6 +23,7 @@ import type { QueryObserver } from './queryObserver' import { notifyManager } from './notifyManager' import { getLogger } from './logger' import { Retryer, isCancelledError } from './retryer' +import { Removable } from './removable' // TYPES @@ -146,25 +146,25 @@ export class Query< TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey -> { +> extends Removable { queryKey: TQueryKey queryHash: string options!: QueryOptions initialState: QueryState revertState?: QueryState state: QueryState - cacheTime!: number meta: QueryMeta | undefined private cache: QueryCache private promise?: Promise - private gcTimeout?: number private retryer?: Retryer private observers: QueryObserver[] private defaultOptions?: QueryOptions private abortSignalConsumed: boolean constructor(config: QueryConfig) { + super() + this.abortSignalConsumed = false this.defaultOptions = config.defaultOptions this.setOptions(config.options) @@ -185,11 +185,7 @@ export class Query< this.meta = options?.meta - // Default to 5 minutes if not cache time is set - this.cacheTime = Math.max( - this.cacheTime || 0, - this.options.cacheTime ?? 5 * 60 * 1000 - ) + this.updateCacheTime(this.options.cacheTime) } setDefaultOptions( @@ -198,22 +194,7 @@ export class Query< this.defaultOptions = options } - private scheduleGc(): void { - this.clearGcTimeout() - - if (isValidTimeout(this.cacheTime)) { - this.gcTimeout = setTimeout(() => { - this.optionalRemove() - }, this.cacheTime) - } - } - - private clearGcTimeout() { - clearTimeout(this.gcTimeout) - this.gcTimeout = undefined - } - - private optionalRemove() { + protected optionalRemove() { if (!this.observers.length && !this.state.isFetching) { this.cache.remove(this) } @@ -260,7 +241,8 @@ export class Query< } destroy(): void { - this.clearGcTimeout() + super.destroy() + this.cancel({ silent: true }) } @@ -508,7 +490,7 @@ export class Query< observer.onQueryUpdate(action) }) - this.cache.notify({ query: this, type: 'queryUpdated', action }) + this.cache.notify({ query: this, type: 'updated', action }) }) } diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts index f57b970ead..a5dadc7022 100644 --- a/src/core/queryCache.ts +++ b/src/core/queryCache.ts @@ -8,7 +8,7 @@ import { Action, Query, QueryState } from './query' import type { QueryKey, QueryOptions } from './types' import { notifyManager } from './notifyManager' import type { QueryClient } from './queryClient' -import { Subscribable } from './subscribable' +import { Notifiable } from './notifiable' import { QueryObserver } from './queryObserver' // TYPES @@ -23,34 +23,34 @@ interface QueryHashMap { } interface NotifyEventQueryAdded { - type: 'queryAdded' + type: 'added' query: Query } interface NotifyEventQueryRemoved { - type: 'queryRemoved' + type: 'removed' query: Query } interface NotifyEventQueryUpdated { - type: 'queryUpdated' + type: 'updated' query: Query action: Action } -interface NotifyEventObserverAdded { +interface NotifyEventQueryObserverAdded { type: 'observerAdded' query: Query observer: QueryObserver } -interface NotifyEventObserverRemoved { +interface NotifyEventQueryObserverRemoved { type: 'observerRemoved' query: Query observer: QueryObserver } -interface NotifyEventObserverResultsUpdated { +interface NotifyEventQueryObserverResultsUpdated { type: 'observerResultsUpdated' query: Query } @@ -59,15 +59,13 @@ type QueryCacheNotifyEvent = | NotifyEventQueryAdded | NotifyEventQueryRemoved | NotifyEventQueryUpdated - | NotifyEventObserverAdded - | NotifyEventObserverRemoved - | NotifyEventObserverResultsUpdated - -type QueryCacheListener = (event?: QueryCacheNotifyEvent) => void + | NotifyEventQueryObserverAdded + | NotifyEventQueryObserverRemoved + | NotifyEventQueryObserverResultsUpdated // CLASS -export class QueryCache extends Subscribable { +export class QueryCache extends Notifiable { config: QueryCacheConfig private queries: Query[] @@ -111,7 +109,7 @@ export class QueryCache extends Subscribable { this.queriesMap[query.queryHash] = query this.queries.push(query) this.notify({ - type: 'queryAdded', + type: 'added', query, }) } @@ -129,7 +127,7 @@ export class QueryCache extends Subscribable { delete this.queriesMap[query.queryHash] } - this.notify({ type: 'queryRemoved', query }) + this.notify({ type: 'removed', query }) } } @@ -179,14 +177,6 @@ export class QueryCache extends Subscribable { : this.queries } - notify(event: QueryCacheNotifyEvent) { - notifyManager.batch(() => { - this.listeners.forEach(listener => { - listener(event) - }) - }) - } - onFocus(): void { notifyManager.batch(() => { this.queries.forEach(query => { diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index fe9cba01aa..fceea94f37 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -711,9 +711,10 @@ export class QueryObserver< // Then the cache listeners if (notifyOptions.cache) { - this.client - .getQueryCache() - .notify({ query: this.currentQuery, type: 'observerResultsUpdated' }) + this.client.getQueryCache().notify({ + query: this.currentQuery, + type: 'observerResultsUpdated', + }) } }) } diff --git a/src/core/removable.ts b/src/core/removable.ts new file mode 100644 index 0000000000..c33f08202c --- /dev/null +++ b/src/core/removable.ts @@ -0,0 +1,35 @@ +import { isValidTimeout } from './utils' + +export abstract class Removable { + cacheTime!: number + private gcTimeout?: number + + destroy(): void { + this.clearGcTimeout() + } + + protected scheduleGc(): void { + this.clearGcTimeout() + + if (isValidTimeout(this.cacheTime)) { + this.gcTimeout = setTimeout(() => { + this.optionalRemove() + }, this.cacheTime) + } + } + + protected updateCacheTime(newCacheTime: number | undefined): void { + // Default to 5 minutes if no cache time is set + this.cacheTime = Math.max( + this.cacheTime || 0, + newCacheTime ?? 5 * 60 * 1000 + ) + } + + protected clearGcTimeout() { + clearTimeout(this.gcTimeout) + this.gcTimeout = undefined + } + + protected abstract optionalRemove(): void +} diff --git a/src/core/tests/mutationCache.test.tsx b/src/core/tests/mutationCache.test.tsx index 2e701427ed..37e3f3017b 100644 --- a/src/core/tests/mutationCache.test.tsx +++ b/src/core/tests/mutationCache.test.tsx @@ -1,5 +1,6 @@ -import { queryKey, mockConsoleError } from '../../reactjs/tests/utils' -import { MutationCache, QueryClient } from '../..' +import { waitFor } from '@testing-library/react' +import { queryKey, mockConsoleError, sleep } from '../../reactjs/tests/utils' +import { MutationCache, MutationObserver, QueryClient } from '../..' describe('mutationCache', () => { describe('MutationCacheConfig.onError', () => { @@ -106,4 +107,135 @@ describe('mutationCache', () => { ).toEqual([mutation2]) }) }) + + describe('garbage collection', () => { + test('should remove unused mutations after cacheTime has elapsed', async () => { + const testCache = new MutationCache() + const testClient = new QueryClient({ mutationCache: testCache }) + const onSuccess = jest.fn() + await testClient.executeMutation({ + mutationKey: ['a', 1], + variables: 1, + cacheTime: 10, + mutationFn: () => Promise.resolve(), + onSuccess, + }) + + expect(testCache.getAll()).toHaveLength(1) + await sleep(10) + await waitFor(() => { + expect(testCache.getAll()).toHaveLength(0) + }) + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + + test('should not remove mutations if there are active observers', async () => { + const queryClient = new QueryClient() + const observer = new MutationObserver(queryClient, { + variables: 1, + cacheTime: 10, + mutationFn: () => Promise.resolve(), + }) + const unsubscribe = observer.subscribe() + + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + observer.mutate(1) + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + await sleep(10) + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + unsubscribe() + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + await sleep(10) + await waitFor(() => { + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + }) + }) + + test('should only remove when the last observer unsubscribes', async () => { + const queryClient = new QueryClient() + const observer1 = new MutationObserver(queryClient, { + variables: 1, + cacheTime: 10, + mutationFn: async () => { + await sleep(10) + return 'update1' + }, + }) + + const observer2 = new MutationObserver(queryClient, { + cacheTime: 10, + mutationFn: async () => { + await sleep(10) + return 'update2' + }, + }) + + await observer1.mutate() + + // we currently have no way to add multiple observers to the same mutation + const currentMutation = observer1['currentMutation']! + currentMutation?.addObserver(observer1) + currentMutation?.addObserver(observer2) + + expect(currentMutation['observers'].length).toEqual(2) + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + + currentMutation?.removeObserver(observer1) + currentMutation?.removeObserver(observer2) + expect(currentMutation['observers'].length).toEqual(0) + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + // wait for cacheTime to gc + await sleep(10) + await waitFor(() => { + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + }) + }) + + test('should be garbage collected later when unsubscribed and mutation is loading', async () => { + const queryClient = new QueryClient() + const onSuccess = jest.fn() + const observer = new MutationObserver(queryClient, { + variables: 1, + cacheTime: 10, + mutationFn: async () => { + await sleep(20) + return 'data' + }, + onSuccess, + }) + const unsubscribe = observer.subscribe() + observer.mutate(1) + unsubscribe() + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + await sleep(10) + // unsubscribe should not remove even though cacheTime has elapsed b/c mutation is still loading + expect(queryClient.getMutationCache().getAll()).toHaveLength(1) + await sleep(10) + // should be removed after an additional cacheTime wait + await waitFor(() => { + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + }) + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + + test('should call callbacks even with cacheTime 0 and mutation still loading', async () => { + const queryClient = new QueryClient() + const onSuccess = jest.fn() + const observer = new MutationObserver(queryClient, { + variables: 1, + cacheTime: 0, + mutationFn: async () => { + return 'data' + }, + onSuccess, + }) + const unsubscribe = observer.subscribe() + observer.mutate(1) + unsubscribe() + await waitFor(() => { + expect(queryClient.getMutationCache().getAll()).toHaveLength(0) + }) + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index edff7dfff3..09fdec6fff 100644 --- a/src/core/tests/queryCache.test.tsx +++ b/src/core/tests/queryCache.test.tsx @@ -23,7 +23,7 @@ describe('queryCache', () => { queryClient.setQueryData(key, 'foo') const query = queryCache.find(key) await sleep(1) - expect(subscriber).toHaveBeenCalledWith({ query, type: 'queryAdded' }) + expect(subscriber).toHaveBeenCalledWith({ query, type: 'added' }) unsubscribe() }) @@ -43,7 +43,7 @@ describe('queryCache', () => { queryClient.prefetchQuery(key, () => 'data') const query = queryCache.find(key) await sleep(100) - expect(callback).toHaveBeenCalledWith({ query, type: 'queryAdded' }) + expect(callback).toHaveBeenCalledWith({ query, type: 'added' }) }) test('should notify subscribers when new query with initialData is added', async () => { diff --git a/src/core/types.ts b/src/core/types.ts index 9bdd3eaf07..3e3817f339 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -536,6 +536,7 @@ export interface MutationOptions< ) => Promise | void retry?: RetryValue retryDelay?: RetryDelayValue + cacheTime?: number _defaulted?: boolean meta?: MutationMeta }