diff --git a/.changeset/query-cache-equality.md b/.changeset/query-cache-equality.md new file mode 100644 index 0000000000..7646c885db --- /dev/null +++ b/.changeset/query-cache-equality.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': minor +--- + +Add optional `equalityFn` and `hashFn` configuration to `QueryCache.queryKey` and `MutationCache.mutationKey` for custom key comparison and hashing. Extend `replaceEqualDeep` with an optional equality function for structural sharing of custom value types. Deprecate the per-query `queryKeyHashFn` option in favor of the global `QueryCache` configuration. diff --git a/docs/framework/react/guides/render-optimizations.md b/docs/framework/react/guides/render-optimizations.md index 9edf7a467e..0c9031cbf5 100644 --- a/docs/framework/react/guides/render-optimizations.md +++ b/docs/framework/react/guides/render-optimizations.md @@ -11,6 +11,23 @@ React Query uses a technique called "structural sharing" to ensure that as many > Note: This optimization only works if the `queryFn` returns JSON compatible data. You can turn it off by setting `structuralSharing: false` globally or on a per-query basis, or you can implement your own structural sharing by passing a function to it. +If your data contains custom value types, you can pass an equality function to `replaceEqualDeep` from your `structuralSharing` function. The previous value is kept when the function returns `true`: + +```tsx +import { replaceEqualDeep, useQuery } from '@tanstack/react-query' + +const equalityFn = (a: unknown, b: unknown) => + a === b || + (a instanceof Date && b instanceof Date && a.getTime() === b.getTime()) + +useQuery({ + queryKey: ['events'], + queryFn: fetchEvents, + structuralSharing: (oldData, newData) => + replaceEqualDeep(oldData, newData, equalityFn), +}) +``` + ### referential identity The top level object returned from `useQuery`, `useInfiniteQuery`, `useMutation` and the Array returned from `useQueries` is **not referentially stable**. It will be a new reference on every render. However, the `data` properties returned from these hooks will be as stable as possible. diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index 10f6df3aee..e249dfcff4 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -108,6 +108,7 @@ const { - `queryKeyHashFn: (queryKey: QueryKey) => string` - Optional - If specified, this function is used to hash the `queryKey` to a string. + - Deprecated: configure `hashFn` on the `queryKey` option of `QueryCache` instead. - `refetchInterval: number | false | ((query: Query) => number | false | undefined)` - Optional - If set to a number, all queries will continuously refetch at this frequency in milliseconds diff --git a/docs/framework/solid/reference/useQuery.md b/docs/framework/solid/reference/useQuery.md index 3e3378cb3e..09b24bdb11 100644 --- a/docs/framework/solid/reference/useQuery.md +++ b/docs/framework/solid/reference/useQuery.md @@ -245,6 +245,7 @@ function App() { - ##### `queryKeyHashFn: (queryKey: QueryKey) => string` - Optional - If specified, this function is used to hash the `queryKey` to a string. + - Deprecated: configure `hashFn` on the `queryKey` option of `QueryCache` instead. - ##### `refetchInterval: number | false | ((query: Query) => number | false | undefined)` - Optional - If set to a number, all queries will continuously refetch at this frequency in milliseconds diff --git a/docs/reference/MutationCache.md b/docs/reference/MutationCache.md index 89613143ce..d8966609dc 100644 --- a/docs/reference/MutationCache.md +++ b/docs/reference/MutationCache.md @@ -28,6 +28,10 @@ Its available methods are: **Options** +- `mutationKey?: { equalityFn?: (a: unknown, b: unknown) => boolean; hashFn?: (mutationKey: MutationKey) => string }` + - Optional + - `equalityFn` compares individual mutation key values during partial matching. It defaults to strict equality. + - `hashFn` is used to hash mutation keys globally for exact matching and mutation defaults. - `onError?: (error: unknown, variables: unknown, onMutateResult: unknown, mutation: Mutation, mutationFnContext: MutationFunctionContext) => Promise | unknown` - Optional - This function will be called if some mutation encounters an error. diff --git a/docs/reference/QueryCache.md b/docs/reference/QueryCache.md index 6a341205cc..e68fa9e1d5 100644 --- a/docs/reference/QueryCache.md +++ b/docs/reference/QueryCache.md @@ -35,6 +35,10 @@ Its available methods are: **Options** +- `queryKey?: { equalityFn?: (a: unknown, b: unknown) => boolean; hashFn?: (queryKey: QueryKey) => string }` + - Optional + - `equalityFn` compares individual query key values during partial matching. It defaults to strict equality. + - `hashFn` is used to hash query keys globally. It takes precedence over a query's deprecated `queryKeyHashFn` option. - `onError?: (error: unknown, query: Query) => void` - Optional - This function will be called if some query encounters an error. diff --git a/packages/query-core/src/__tests__/mutationCache.test.tsx b/packages/query-core/src/__tests__/mutationCache.test.tsx index be46802a8e..d8e0be0053 100644 --- a/packages/query-core/src/__tests__/mutationCache.test.tsx +++ b/packages/query-core/src/__tests__/mutationCache.test.tsx @@ -303,6 +303,49 @@ describe('mutationCache', () => { }), ).toEqual(mutation) }) + + it('should use the MutationCache equality function for partial mutation key matching', () => { + const equalityFn = (a: unknown, b: unknown) => + typeof a === 'string' && typeof b === 'string' + ? a.toLowerCase() === b.toLowerCase() + : a === b + const testCache = new MutationCache({ mutationKey: { equalityFn } }) + const testClient = new QueryClient({ mutationCache: testCache }) + const mutation = testCache.build(testClient, { + mutationKey: ['todos', { status: 'done' }], + mutationFn: () => Promise.resolve(), + }) + + expect( + testCache.find({ + mutationKey: ['TODOS', { status: 'DONE' }], + exact: false, + }), + ).toBe(mutation) + expect( + testCache.findAll({ + mutationKey: ['TODOS', { status: 'DONE' }], + }), + ).toEqual([mutation]) + + testClient.clear() + }) + + it('should use the MutationCache mutation key hash function for exact matching', () => { + const key = ['todos', { status: 'done' }] + const hashFn = vi.fn(() => 'custom-hash') + const testCache = new MutationCache({ mutationKey: { hashFn } }) + const testClient = new QueryClient({ mutationCache: testCache }) + const mutation = testCache.build(testClient, { + mutationKey: key, + mutationFn: () => Promise.resolve(), + }) + + expect(testCache.find({ mutationKey: [...key] })).toBe(mutation) + expect(hashFn).toHaveBeenCalledWith(key) + + testClient.clear() + }) }) describe('findAll', () => { diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index 669fc42ac3..90a7d2132b 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -14,7 +14,7 @@ import { hydrate, noop, } from '..' -import { hashQueryKeyByOptions } from '../utils' +import { hashKeyByOptions } from '../utils' import { mockOnlineManagerIsOnline, setIsServer } from './utils' import type { QueryFunctionContext, QueryKey, QueryObserverResult } from '..' @@ -1267,7 +1267,10 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashQueryKeyByOptions(key), + queryHash: hashKeyByOptions( + key, + queryClient.getQueryCache().config.queryKey, + ), }) query.addObserver(observer) @@ -1303,7 +1306,10 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashQueryKeyByOptions(key), + queryHash: hashKeyByOptions( + key, + queryClient.getQueryCache().config.queryKey, + ), options: { queryFn: () => 'data', initialData: initialDataFn, @@ -1388,7 +1394,10 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashQueryKeyByOptions(key), + queryHash: hashKeyByOptions( + key, + queryClient.getQueryCache().config.queryKey, + ), options: { queryFn }, }) diff --git a/packages/query-core/src/__tests__/queryCache.test.tsx b/packages/query-core/src/__tests__/queryCache.test.tsx index 0758b96226..8e66c18482 100644 --- a/packages/query-core/src/__tests__/queryCache.test.tsx +++ b/packages/query-core/src/__tests__/queryCache.test.tsx @@ -191,6 +191,30 @@ describe('queryCache', () => { const query = queryCache.find({ queryKey: key, exact: false })! expect(query.state.data).toBe('data1') }) + + it('should use the QueryCache equality function for partial query key matching', () => { + const equalityFn = (a: unknown, b: unknown) => + typeof a === 'string' && typeof b === 'string' + ? a.toLowerCase() === b.toLowerCase() + : a === b + const testCache = new QueryCache({ queryKey: { equalityFn } }) + const testClient = new QueryClient({ queryCache: testCache }) + const query = testClient.getQueryCache().build(testClient, { + queryKey: ['todos', { status: 'done' }], + }) + + expect( + testCache.find({ + queryKey: ['TODOS', { status: 'DONE' }], + exact: false, + }), + ).toBe(query) + expect( + testCache.findAll({ queryKey: ['TODOS', { status: 'DONE' }] }), + ).toEqual([query]) + + testClient.clear() + }) }) describe('findAll', () => { @@ -342,6 +366,59 @@ describe('queryCache', () => { .catch(noop) expect(queryCache.findAll().length).toBe(2) }) + + it('should return all the queries when key contains object with an undefined property (#3741)', async () => { + const baseKey = queryKey() + + const client = new QueryClient({ + queryCache: new QueryCache({ + queryKey: { + equalityFn: (a, b) => b === undefined || Object.is(a, b), + }, + }), + }) + + const createKey = (id?: number) => [{ ...baseKey, a: id }] + + await client.query({ queryKey: createKey(1), queryFn: () => 'data1' }) + await client.query({ + queryKey: createKey(), + queryFn: () => 'data-nothing', + }) + expect( + client.getQueryCache().findAll({ queryKey: createKey() }), + ).toHaveLength(2) + }) + + it('should invalidate matching dates (#10982)', async () => { + const client = new QueryClient({ + queryCache: new QueryCache({ + queryKey: { + equalityFn: (a, b) => { + if (a instanceof Date && b instanceof Date) { + return a.toISOString() === b.toISOString() + } + return a === b + }, + }, + }), + }) + + await client.query({ + queryKey: ['report', { from: new Date('2020-01-01') }], + queryFn: () => 'data1', + }) + await client.query({ + queryKey: ['report', { from: new Date('2021-06-25') }], + queryFn: () => 'data2', + }) + + expect( + client.getQueryCache().findAll({ + queryKey: ['report', { from: new Date('2020-01-01') }], + }), + ).toHaveLength(1) + }) }) describe('QueryCacheConfig error callbacks', () => { @@ -404,6 +481,38 @@ describe('queryCache', () => { expect(query.queryHash).toBe(hashKey(key)) }) + it('should use the QueryCache query key hash function', () => { + const key = queryKey() + const hashFn = vi.fn(() => 'custom-hash') + const testCache = new QueryCache({ queryKey: { hashFn } }) + const testClient = new QueryClient({ queryCache: testCache }) + + const query = testCache.build(testClient, { queryKey: key }) + + expect(query.queryHash).toBe('custom-hash') + expect(testCache.find({ queryKey: key })).toBe(query) + expect(hashFn).toHaveBeenCalledWith(key) + }) + + it('should prefer the QueryCache query key hash function over the query option', () => { + const key = queryKey() + const cacheHashFn = vi.fn(() => 'cache-hash') + const queryHashFn = vi.fn(() => 'query-hash') + const testCache = new QueryCache({ + queryKey: { hashFn: cacheHashFn }, + }) + const testClient = new QueryClient({ queryCache: testCache }) + + const query = testCache.build(testClient, { + queryKey: key, + queryKeyHashFn: queryHashFn, + }) + + expect(query.queryHash).toBe('cache-hash') + expect(cacheHashFn).toHaveBeenCalledWith(key) + expect(queryHashFn).not.toHaveBeenCalled() + }) + it('should use provided queryHash instead of computing it', () => { const key = queryKey() const customHash = 'custom-hash' diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index 480639695b..eb213afd6b 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { CancelledError, + MutationCache, MutationObserver, + QueryCache, QueryClient, QueryObserver, dehydrate, @@ -16,7 +18,6 @@ import { mockOnlineManagerIsOnline } from './utils' import type { InfiniteData, Query, - QueryCache, QueryFunction, QueryObserverOptions, } from '..' @@ -103,6 +104,26 @@ describe('queryClient', () => { expect(data).toBe('data') }) + it('should use the QueryCache equality function when matching defaults', () => { + const equalityFn = (a: unknown, b: unknown) => + typeof a === 'string' && typeof b === 'string' + ? a.toLowerCase() === b.toLowerCase() + : a === b + const testClient = new QueryClient({ + queryCache: new QueryCache({ queryKey: { equalityFn } }), + }) + + testClient.setQueryDefaults(['todos', { status: 'done' }], { + staleTime: 1000, + }) + + expect( + testClient.getQueryDefaults(['TODOS', { status: 'DONE' }]), + ).toMatchObject({ staleTime: 1000 }) + + testClient.clear() + }) + it('should not match if the query key is a subset', async () => { const key = queryKey() queryClient.setQueryDefaults([key, 'a'], { @@ -3129,5 +3150,25 @@ describe('queryClient', () => { mutationOptions2, ) }) + + it('should use the MutationCache equality function when matching defaults', () => { + const equalityFn = (a: unknown, b: unknown) => + typeof a === 'string' && typeof b === 'string' + ? a.toLowerCase() === b.toLowerCase() + : a === b + const testClient = new QueryClient({ + mutationCache: new MutationCache({ mutationKey: { equalityFn } }), + }) + + testClient.setMutationDefaults(['todos', { status: 'done' }], { + retry: false, + }) + + expect( + testClient.getMutationDefaults(['TODOS', { status: 'DONE' }]), + ).toMatchObject({ retry: false }) + + testClient.clear() + }) }) }) diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index c9566ddd37..e9873be8a2 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -7,7 +7,7 @@ import { addToStart, ensureQueryFn, hashKey, - hashQueryKeyByOptions, + hashKeyByOptions, isPlainArray, isPlainObject, isValidTimeout, @@ -23,13 +23,13 @@ import { Mutation } from '../mutation' import type { QueryFunctionContext } from '..' describe('core/utils', () => { - describe('hashQueryKeyByOptions', () => { + describe('hashKeyByOptions', () => { it('should use custom hash function when provided in options', () => { const key = ['test', { a: 1, b: 2 }] const customHashFn = vi.fn(() => 'custom-hash') - const result = hashQueryKeyByOptions(key, { - queryKeyHashFn: customHashFn, + const result = hashKeyByOptions(key, { + hashFn: customHashFn, }) expect(customHashFn).toHaveBeenCalledWith(key) @@ -39,7 +39,7 @@ describe('core/utils', () => { it('should use default hash function when no options provided', () => { const key = ['test', { a: 1, b: 2 }] const defaultResult = hashKey(key) - const result = hashQueryKeyByOptions(key) + const result = hashKeyByOptions(key, undefined) expect(result).toEqual(defaultResult) }) @@ -126,6 +126,38 @@ describe('core/utils', () => { }) describe('partialMatchKey', () => { + it('should use a custom equality function', () => { + const equalityFn = (a: unknown, b: unknown) => + typeof a === 'string' && typeof b === 'string' + ? a.toLowerCase() === b.toLowerCase() + : a === b + const a = ['todos', { status: 'done' }] + const b = ['TODOS', { status: 'DONE' }] + + expect(partialMatchKey(a, b)).toBe(false) + expect(partialMatchKey(a, b, equalityFn)).toBe(true) + }) + + it('should not structurally match different non-plain objects', () => { + const equalityFn = (a: unknown, b: unknown) => { + if (a instanceof Date && b instanceof Date) { + return a.toISOString() === b.toISOString() + } + return a === b + } + const a = ['report', { from: new Date('2021-06-25') }] + const b = ['report', { from: new Date('2020-01-01') }] + + expect(partialMatchKey(a, b, equalityFn)).toBe(false) + expect( + partialMatchKey( + a, + ['report', { from: new Date('2021-06-25') }], + equalityFn, + ), + ).toBe(true) + }) + it('should return `true` if a includes b', () => { const a = [{ a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }] const b = [{ a: { b: 'b' }, c: 'c', d: [] }] @@ -201,6 +233,17 @@ describe('core/utils', () => { expect(replaceEqualDeep(date1, date2)).toBe(date2) }) + it('should use a custom equality function for value objects', () => { + const equalityFn = (a: unknown, b: unknown) => + a === b || + (a instanceof Date && b instanceof Date && a.getTime() === b.getTime()) + const prev = { date: new Date(0) } + const next = { date: new Date(0) } + + expect(replaceEqualDeep(prev, next)).not.toBe(prev) + expect(replaceEqualDeep(prev, next, equalityFn)).toBe(prev) + }) + it('should return the next value when the previous value is a different type', () => { const array = [1] const object = { a: 'a' } diff --git a/packages/query-core/src/mutationCache.ts b/packages/query-core/src/mutationCache.ts index 109e9a94b4..6dfd05550e 100644 --- a/packages/query-core/src/mutationCache.ts +++ b/packages/query-core/src/mutationCache.ts @@ -4,8 +4,10 @@ import { matchMutation, noop } from './utils' import { Subscribable } from './subscribable' import type { MutationObserver } from './mutationObserver' import type { + CacheKeyConfig, DefaultError, MutationFunctionContext, + MutationKey, MutationOptions, NotifyEvent, } from './types' @@ -16,6 +18,7 @@ import type { MutationFilters } from './utils' // TYPES export interface MutationCacheConfig { + mutationKey?: CacheKeyConfig onError?: ( error: DefaultError, variables: unknown, @@ -212,12 +215,14 @@ export class MutationCache extends Subscribable { const defaultedFilters = { exact: true, ...filters } return this.getAll().find((mutation) => - matchMutation(defaultedFilters, mutation), + matchMutation(defaultedFilters, mutation, this.config.mutationKey), ) as Mutation | undefined } findAll(filters: MutationFilters = {}): Array { - return this.getAll().filter((mutation) => matchMutation(filters, mutation)) + return this.getAll().filter((mutation) => + matchMutation(filters, mutation, this.config.mutationKey), + ) } notify(event: MutationCacheNotifyEvent) { diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 8164e2ad02..09b285ff76 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -1,7 +1,7 @@ import { getDefaultState } from './mutation' import { notifyManager } from './notifyManager' import { Subscribable } from './subscribable' -import { hashKey, shallowEqualObjects } from './utils' +import { hashKeyByOptions, shallowEqualObjects } from './utils' import type { QueryClient } from './queryClient' import type { DefaultError, @@ -85,7 +85,14 @@ export class MutationObserver< if ( prevOptions?.mutationKey && this.options.mutationKey && - hashKey(prevOptions.mutationKey) !== hashKey(this.options.mutationKey) + hashKeyByOptions( + prevOptions.mutationKey, + this.#client.getMutationCache().config.mutationKey, + ) !== + hashKeyByOptions( + this.options.mutationKey, + this.#client.getMutationCache().config.mutationKey, + ) ) { this.reset() } else if (this.#currentMutation?.state.status === 'pending') { diff --git a/packages/query-core/src/queryCache.ts b/packages/query-core/src/queryCache.ts index 030f092042..a54ebd640e 100644 --- a/packages/query-core/src/queryCache.ts +++ b/packages/query-core/src/queryCache.ts @@ -1,10 +1,11 @@ -import { hashQueryKeyByOptions, matchQuery } from './utils' +import { hashKeyByOptions, matchQuery } from './utils' import { Query } from './query' import { notifyManager } from './notifyManager' import { Subscribable } from './subscribable' import type { QueryFilters } from './utils' import type { Action, QueryState } from './query' import type { + CacheKeyConfig, DefaultError, NotifyEvent, QueryKey, @@ -17,6 +18,7 @@ import type { QueryObserver } from './queryObserver' // TYPES export interface QueryCacheConfig { + queryKey?: CacheKeyConfig onError?: ( error: DefaultError, query: Query, @@ -112,7 +114,8 @@ export class QueryCache extends Subscribable { ): Query { const queryKey = options.queryKey const queryHash = - options.queryHash ?? hashQueryKeyByOptions(queryKey, options) + options.queryHash ?? + hashKeyByOptions(queryKey, this.config.queryKey, options) let query = this.get(queryHash) if (!query) { @@ -186,14 +189,16 @@ export class QueryCache extends Subscribable { const defaultedFilters = { exact: true, ...filters } return this.getAll().find((query) => - matchQuery(defaultedFilters, query), + matchQuery(defaultedFilters, query, this.config.queryKey), ) as Query | undefined } findAll(filters: QueryFilters = {}): Array { const queries = this.getAll() return Object.keys(filters).length > 0 - ? queries.filter((query) => matchQuery(filters, query)) + ? queries.filter((query) => + matchQuery(filters, query, this.config.queryKey), + ) : queries } diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 03dbcadb55..01a3263211 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -1,7 +1,6 @@ import { functionalUpdate, - hashKey, - hashQueryKeyByOptions, + hashKeyByOptions, noop, partialMatchKey, resolveQueryValue, @@ -560,10 +559,13 @@ export class QueryClient { > >, ): void { - this.#queryDefaults.set(hashKey(queryKey), { - queryKey, - defaultOptions: options, - }) + this.#queryDefaults.set( + hashKeyByOptions(queryKey, this.#queryCache.config.queryKey), + { + queryKey, + defaultOptions: options, + }, + ) } getQueryDefaults( @@ -577,7 +579,13 @@ export class QueryClient { > = {} defaults.forEach((queryDefault) => { - if (partialMatchKey(queryKey, queryDefault.queryKey)) { + if ( + partialMatchKey( + queryKey, + queryDefault.queryKey, + this.#queryCache.config.queryKey?.equalityFn, + ) + ) { Object.assign(result, queryDefault.defaultOptions) } }) @@ -596,10 +604,13 @@ export class QueryClient { 'mutationKey' >, ): void { - this.#mutationDefaults.set(hashKey(mutationKey), { - mutationKey, - defaultOptions: options, - }) + this.#mutationDefaults.set( + hashKeyByOptions(mutationKey, this.#mutationCache.config.mutationKey), + { + mutationKey, + defaultOptions: options, + }, + ) } getMutationDefaults( @@ -613,7 +624,13 @@ export class QueryClient { > = {} defaults.forEach((queryDefault) => { - if (partialMatchKey(mutationKey, queryDefault.mutationKey)) { + if ( + partialMatchKey( + mutationKey, + queryDefault.mutationKey, + this.#mutationCache.config.mutationKey?.equalityFn, + ) + ) { Object.assign(result, queryDefault.defaultOptions) } }) @@ -670,8 +687,9 @@ export class QueryClient { } if (!defaultedOptions.queryHash) { - defaultedOptions.queryHash = hashQueryKeyByOptions( + defaultedOptions.queryHash = hashKeyByOptions( defaultedOptions.queryKey, + this.#queryCache.config.queryKey, defaultedOptions, ) } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index ad29a77f50..2d26c11bc8 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -193,6 +193,26 @@ export type QueryKeyHashFunction = ( queryKey: TQueryKey, ) => string +export type EqualityFn = (a: unknown, b: unknown) => boolean + +export type CacheKeyHashFunction< + TCacheKey extends ReadonlyArray = ReadonlyArray, +> = (cacheKey: TCacheKey) => string + +export interface CacheKeyConfig< + TCacheKey extends ReadonlyArray = ReadonlyArray, +> { + /** + * Function used to compare values while partially matching keys. + * Defaults to strict equality. + */ + equalityFn?: EqualityFn + /** + * Function used to hash keys globally. + */ + hashFn?: CacheKeyHashFunction +} + export type GetPreviousPageParamFunction = ( firstPage: TQueryFnData, allPages: Array, @@ -255,6 +275,9 @@ export interface QueryOptions< persister?: QueryPersister, TPageParam> queryHash?: string queryKey?: TQueryKey + /** + * @deprecated Use `hashFn` in the `queryKey` configuration of `QueryCache` instead. + */ queryKeyHashFn?: QueryKeyHashFunction initialData?: TData | InitialDataFunction initialDataUpdatedAt?: number | (() => number | undefined) diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 629115e8fa..bb71737422 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,6 +1,8 @@ import { timeoutManager } from './timeoutManager' import type { + CacheKeyConfig, DefaultError, + EqualityFn, FetchStatus, MutationKey, MutationStatus, @@ -134,6 +136,7 @@ export function resolveQueryValue< export function matchQuery( filters: QueryFilters, query: Query, + keyConfig?: CacheKeyConfig, ): boolean { const { type = 'all', @@ -146,10 +149,14 @@ export function matchQuery( if (queryKey) { if (exact) { - if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) { + if ( + query.queryHash !== hashKeyByOptions(queryKey, keyConfig, query.options) + ) { return false } - } else if (!partialMatchKey(query.queryKey, queryKey)) { + } else if ( + !partialMatchKey(query.queryKey, queryKey, keyConfig?.equalityFn) + ) { return false } } @@ -182,6 +189,7 @@ export function matchQuery( export function matchMutation( filters: MutationFilters, mutation: Mutation, + keyConfig?: CacheKeyConfig, ): boolean { const { exact, status, predicate, mutationKey } = filters if (mutationKey) { @@ -189,10 +197,19 @@ export function matchMutation( return false } if (exact) { - if (hashKey(mutation.options.mutationKey) !== hashKey(mutationKey)) { + if ( + hashKeyByOptions(mutation.options.mutationKey, keyConfig) !== + hashKeyByOptions(mutationKey, keyConfig) + ) { return false } - } else if (!partialMatchKey(mutation.options.mutationKey, mutationKey)) { + } else if ( + !partialMatchKey( + mutation.options.mutationKey, + mutationKey, + keyConfig?.equalityFn, + ) + ) { return false } } @@ -208,12 +225,15 @@ export function matchMutation( return true } -export function hashQueryKeyByOptions( - queryKey: TQueryKey, +export function hashKeyByOptions< + TCacheKey extends ReadonlyArray = ReadonlyArray, +>( + cacheKey: TCacheKey, + keyConfig: CacheKeyConfig | undefined, options?: Pick, 'queryKeyHashFn'>, ): string { - const hashFn = options?.queryKeyHashFn || hashKey - return hashFn(queryKey) + const queryKeyHashFn = keyConfig?.hashFn ?? options?.queryKeyHashFn ?? hashKey + return queryKeyHashFn(cacheKey) } /** @@ -233,12 +253,27 @@ export function hashKey(queryKey: QueryKey | MutationKey): string { ) } +const defaultEqualityFn: EqualityFn = (a, b) => a === b + /** * Checks if key `b` partially matches with key `a`. */ -export function partialMatchKey(a: QueryKey, b: QueryKey): boolean -export function partialMatchKey(a: any, b: any): boolean { - if (a === b) { +export function partialMatchKey( + a: QueryKey, + b: QueryKey, + equalityFn?: EqualityFn, +): boolean +export function partialMatchKey( + a: unknown, + b: unknown, + equalityFn?: EqualityFn, +): boolean +export function partialMatchKey( + a: any, + b: any, + equalityFn: EqualityFn = defaultEqualityFn, +): boolean { + if (equalityFn(a, b)) { return true } @@ -246,19 +281,19 @@ export function partialMatchKey(a: any, b: any): boolean { return false } - if (a && b && typeof a === 'object' && typeof b === 'object') { - if (Array.isArray(a) && Array.isArray(b)) { - for (let i = 0; i < b.length; i++) { - if (!partialMatchKey(a[i], b[i])) { - return false - } + if (isPlainArray(a) && isPlainArray(b)) { + for (let i = 0; i < b.length; i++) { + if (!partialMatchKey(a[i], b[i], equalityFn)) { + return false } - return true } + return true + } + if (isPlainObject(a) && isPlainObject(b)) { const bKeys = Object.keys(b) for (const key of bKeys) { - if (!partialMatchKey(a[key], b[key])) { + if (!partialMatchKey(a[key], b[key], equalityFn)) { return false } } @@ -274,10 +309,28 @@ const hasOwn = Object.prototype.hasOwnProperty * This function returns `a` if `b` is deeply equal. * If not, it will replace any deeply equal children of `b` with those of `a`. * This can be used for structural sharing between JSON values for example. + * An optional equality function can be used for custom value types. */ -export function replaceEqualDeep(a: unknown, b: T, depth?: number): T -export function replaceEqualDeep(a: any, b: any, depth = 0): any { - if (a === b) { +export function replaceEqualDeep( + a: unknown, + b: T, + equalityFn?: EqualityFn, +): T +export function replaceEqualDeep( + a: any, + b: any, + equalityFn: EqualityFn = defaultEqualityFn, +): any { + return replaceEqualDeepInternal(a, b, equalityFn, 0) +} + +function replaceEqualDeepInternal( + a: any, + b: any, + equalityFn: EqualityFn, + depth: number, +): any { + if (equalityFn(a, b)) { return a } @@ -300,7 +353,7 @@ export function replaceEqualDeep(a: any, b: any, depth = 0): any { const aItem = a[key] const bItem = b[key] - if (aItem === bItem) { + if (equalityFn(aItem, bItem)) { copy[key] = aItem if (array ? i < aSize : hasOwn.call(a, key)) equalItems++ continue @@ -316,7 +369,7 @@ export function replaceEqualDeep(a: any, b: any, depth = 0): any { continue } - const v = replaceEqualDeep(aItem, bItem, depth + 1) + const v = replaceEqualDeepInternal(aItem, bItem, equalityFn, depth + 1) copy[key] = v if (v === aItem) equalItems++ }