From 16cfd3d8a9fe3e8b9ca8e3fe8026585cd637ad1f Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 30 Aug 2026 12:13:54 +0200 Subject: [PATCH 01/12] feat: EqualityFn --- .changeset/query-cache-equality.md | 5 +++ docs/reference/MutationCache.md | 3 ++ docs/reference/QueryCache.md | 3 ++ .../src/__tests__/mutationCache.test.tsx | 27 ++++++++++++ .../src/__tests__/queryCache.test.tsx | 24 +++++++++++ .../src/__tests__/queryClient.test.tsx | 43 ++++++++++++++++++- .../query-core/src/__tests__/utils.test.tsx | 12 ++++++ packages/query-core/src/mutationCache.ts | 12 +++++- packages/query-core/src/queryCache.ts | 12 +++++- packages/query-core/src/queryClient.ts | 16 ++++++- packages/query-core/src/types.ts | 2 + packages/query-core/src/utils.ts | 29 ++++++++++--- 12 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 .changeset/query-cache-equality.md diff --git a/.changeset/query-cache-equality.md b/.changeset/query-cache-equality.md new file mode 100644 index 00000000000..4660ab38cb8 --- /dev/null +++ b/.changeset/query-cache-equality.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': minor +--- + +Add an optional `equalityFn` to `QueryCache` and `MutationCache` for custom key comparisons. diff --git a/docs/reference/MutationCache.md b/docs/reference/MutationCache.md index 89613143ce7..603094c9cc0 100644 --- a/docs/reference/MutationCache.md +++ b/docs/reference/MutationCache.md @@ -28,6 +28,9 @@ Its available methods are: **Options** +- `equalityFn?: (a: unknown, b: unknown) => boolean` + - Optional + - This function compares individual mutation key values during partial matching. It defaults to strict equality. - `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 6a341205ccf..bb79e5fc12e 100644 --- a/docs/reference/QueryCache.md +++ b/docs/reference/QueryCache.md @@ -35,6 +35,9 @@ Its available methods are: **Options** +- `equalityFn?: (a: unknown, b: unknown) => boolean` + - Optional + - This function compares individual query key values during partial matching. It defaults to strict equality. - `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 be46802a8e2..bdbdad472b3 100644 --- a/packages/query-core/src/__tests__/mutationCache.test.tsx +++ b/packages/query-core/src/__tests__/mutationCache.test.tsx @@ -303,6 +303,33 @@ 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({ 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() + }) }) describe('findAll', () => { diff --git a/packages/query-core/src/__tests__/queryCache.test.tsx b/packages/query-core/src/__tests__/queryCache.test.tsx index 307cb2d987c..ed6d981a355 100644 --- a/packages/query-core/src/__tests__/queryCache.test.tsx +++ b/packages/query-core/src/__tests__/queryCache.test.tsx @@ -175,6 +175,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({ 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', () => { diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index f68670104a2..6498af039a0 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 '..' @@ -101,6 +102,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({ 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'], { @@ -3113,5 +3134,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({ 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 c9566ddd376..f5036fe27ae 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -126,6 +126,18 @@ 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 return `true` if a includes b', () => { const a = [{ a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }] const b = [{ a: { b: 'b' }, c: 'c', d: [] }] diff --git a/packages/query-core/src/mutationCache.ts b/packages/query-core/src/mutationCache.ts index 109e9a94b44..05fd754f689 100644 --- a/packages/query-core/src/mutationCache.ts +++ b/packages/query-core/src/mutationCache.ts @@ -5,6 +5,7 @@ import { Subscribable } from './subscribable' import type { MutationObserver } from './mutationObserver' import type { DefaultError, + EqualityFn, MutationFunctionContext, MutationOptions, NotifyEvent, @@ -16,6 +17,11 @@ import type { MutationFilters } from './utils' // TYPES export interface MutationCacheConfig { + /** + * Function used to compare values while partially matching mutation keys. + * Defaults to strict equality. + */ + equalityFn?: EqualityFn onError?: ( error: DefaultError, variables: unknown, @@ -212,12 +218,14 @@ export class MutationCache extends Subscribable { const defaultedFilters = { exact: true, ...filters } return this.getAll().find((mutation) => - matchMutation(defaultedFilters, mutation), + matchMutation(defaultedFilters, mutation, this.config.equalityFn), ) 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.equalityFn), + ) } notify(event: MutationCacheNotifyEvent) { diff --git a/packages/query-core/src/queryCache.ts b/packages/query-core/src/queryCache.ts index 030f0920427..1eef6271411 100644 --- a/packages/query-core/src/queryCache.ts +++ b/packages/query-core/src/queryCache.ts @@ -6,6 +6,7 @@ import type { QueryFilters } from './utils' import type { Action, QueryState } from './query' import type { DefaultError, + EqualityFn, NotifyEvent, QueryKey, QueryOptions, @@ -17,6 +18,11 @@ import type { QueryObserver } from './queryObserver' // TYPES export interface QueryCacheConfig { + /** + * Function used to compare values while partially matching query keys. + * Defaults to strict equality. + */ + equalityFn?: EqualityFn onError?: ( error: DefaultError, query: Query, @@ -186,14 +192,16 @@ export class QueryCache extends Subscribable { const defaultedFilters = { exact: true, ...filters } return this.getAll().find((query) => - matchQuery(defaultedFilters, query), + matchQuery(defaultedFilters, query, this.config.equalityFn), ) 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.equalityFn), + ) : queries } diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 03dbcadb55c..a4204847916 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -577,7 +577,13 @@ export class QueryClient { > = {} defaults.forEach((queryDefault) => { - if (partialMatchKey(queryKey, queryDefault.queryKey)) { + if ( + partialMatchKey( + queryKey, + queryDefault.queryKey, + this.#queryCache.config.equalityFn, + ) + ) { Object.assign(result, queryDefault.defaultOptions) } }) @@ -613,7 +619,13 @@ export class QueryClient { > = {} defaults.forEach((queryDefault) => { - if (partialMatchKey(mutationKey, queryDefault.mutationKey)) { + if ( + partialMatchKey( + mutationKey, + queryDefault.mutationKey, + this.#mutationCache.config.equalityFn, + ) + ) { Object.assign(result, queryDefault.defaultOptions) } }) diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index ad29a77f506..46d478e051e 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -193,6 +193,8 @@ export type QueryKeyHashFunction = ( queryKey: TQueryKey, ) => string +export type EqualityFn = (a: unknown, b: unknown) => boolean + export type GetPreviousPageParamFunction = ( firstPage: TQueryFnData, allPages: Array, diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 629115e8fae..49b39ea9cde 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,6 +1,7 @@ import { timeoutManager } from './timeoutManager' import type { DefaultError, + EqualityFn, FetchStatus, MutationKey, MutationStatus, @@ -134,6 +135,7 @@ export function resolveQueryValue< export function matchQuery( filters: QueryFilters, query: Query, + equalityFn?: EqualityFn, ): boolean { const { type = 'all', @@ -149,7 +151,7 @@ export function matchQuery( if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) { return false } - } else if (!partialMatchKey(query.queryKey, queryKey)) { + } else if (!partialMatchKey(query.queryKey, queryKey, equalityFn)) { return false } } @@ -182,6 +184,7 @@ export function matchQuery( export function matchMutation( filters: MutationFilters, mutation: Mutation, + equalityFn?: EqualityFn, ): boolean { const { exact, status, predicate, mutationKey } = filters if (mutationKey) { @@ -192,7 +195,9 @@ export function matchMutation( if (hashKey(mutation.options.mutationKey) !== hashKey(mutationKey)) { return false } - } else if (!partialMatchKey(mutation.options.mutationKey, mutationKey)) { + } else if ( + !partialMatchKey(mutation.options.mutationKey, mutationKey, equalityFn) + ) { return false } } @@ -233,12 +238,22 @@ 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: any, + b: any, + equalityFn: EqualityFn = defaultEqualityFn, +): boolean { + if (equalityFn(a, b)) { return true } @@ -249,7 +264,7 @@ export function partialMatchKey(a: any, b: any): boolean { 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])) { + if (!partialMatchKey(a[i], b[i], equalityFn)) { return false } } @@ -258,7 +273,7 @@ export function partialMatchKey(a: any, b: any): boolean { 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 } } From b8ce6594a71cb990c0451c1106155ee1faccd1bd Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 30 Aug 2026 14:28:06 +0200 Subject: [PATCH 02/12] equalityFn for replaceEqualDeep --- .changeset/query-cache-equality.md | 2 +- .../react/guides/render-optimizations.md | 19 +++++++++++++ .../query-core/src/__tests__/utils.test.tsx | 11 ++++++++ packages/query-core/src/utils.ts | 28 +++++++++++++++---- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/.changeset/query-cache-equality.md b/.changeset/query-cache-equality.md index 4660ab38cb8..ba1fbe6ca5f 100644 --- a/.changeset/query-cache-equality.md +++ b/.changeset/query-cache-equality.md @@ -2,4 +2,4 @@ '@tanstack/query-core': minor --- -Add an optional `equalityFn` to `QueryCache` and `MutationCache` for custom key comparisons. +Add an optional `equalityFn` to `QueryCache` and `MutationCache` for custom key comparisons. Extend `replaceEqualDeep` with an optional equality function for structural sharing of custom value types. diff --git a/docs/framework/react/guides/render-optimizations.md b/docs/framework/react/guides/render-optimizations.md index 9edf7a467e7..c73631cea17 100644 --- a/docs/framework/react/guides/render-optimizations.md +++ b/docs/framework/react/guides/render-optimizations.md @@ -11,6 +11,25 @@ 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 || + (typeof a === 'bigint' && + typeof b === 'bigint' && + a.toString() === b.toString()) + +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/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index f5036fe27ae..00323d2a7f9 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -213,6 +213,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/utils.ts b/packages/query-core/src/utils.ts index 49b39ea9cde..871cccf27db 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -289,10 +289,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 } @@ -315,7 +333,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 @@ -331,7 +349,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++ } From 05826ec2fd0611938e21507d97501e95b020a9b5 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 30 Aug 2026 16:38:50 +0200 Subject: [PATCH 03/12] show that custom equalityFn can work around issue 3741 --- .../query-core/src/__tests__/queryCache.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/query-core/src/__tests__/queryCache.test.tsx b/packages/query-core/src/__tests__/queryCache.test.tsx index ed6d981a355..981ea92ea84 100644 --- a/packages/query-core/src/__tests__/queryCache.test.tsx +++ b/packages/query-core/src/__tests__/queryCache.test.tsx @@ -336,6 +336,22 @@ describe('queryCache', () => { }) 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({ + 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(), exact: true })).toHaveLength(1) + }) }) describe('QueryCacheConfig error callbacks', () => { From 91c9bfa7e880d1fdb91ac25027a4f1d5b7ddd777 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 30 Aug 2026 19:43:24 +0200 Subject: [PATCH 04/12] fix: limit recursion to plain objects --- .../src/__tests__/queryCache.test.tsx | 37 ++++++++++++++++++- .../query-core/src/__tests__/utils.test.tsx | 20 ++++++++++ packages/query-core/src/utils.ts | 19 ++++++---- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/query-core/src/__tests__/queryCache.test.tsx b/packages/query-core/src/__tests__/queryCache.test.tsx index 981ea92ea84..2b1d8e87e36 100644 --- a/packages/query-core/src/__tests__/queryCache.test.tsx +++ b/packages/query-core/src/__tests__/queryCache.test.tsx @@ -349,8 +349,41 @@ describe('queryCache', () => { 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(), exact: true })).toHaveLength(1) + 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({ + 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) }) }) diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index 00323d2a7f9..f827d2113cc 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -138,6 +138,26 @@ describe('core/utils', () => { 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: [] }] diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 871cccf27db..0eba5b66ccc 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -248,6 +248,11 @@ export function partialMatchKey( b: QueryKey, equalityFn?: EqualityFn, ): boolean +export function partialMatchKey( + a: unknown, + b: unknown, + equalityFn?: EqualityFn, +): boolean export function partialMatchKey( a: any, b: any, @@ -261,16 +266,16 @@ export function partialMatchKey( 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], equalityFn)) { - 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], equalityFn)) { From a5f88c78e7772f93a82da43e023b1fe25f1d4b67 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 30 Aug 2026 20:51:27 +0200 Subject: [PATCH 05/12] global cache config --- .changeset/query-cache-equality.md | 2 +- docs/framework/react/reference/useQuery.md | 1 + docs/framework/solid/reference/useQuery.md | 1 + docs/reference/MutationCache.md | 5 +- docs/reference/QueryCache.md | 5 +- .../src/__tests__/mutationCache.test.tsx | 18 ++++++- .../src/__tests__/queryCache.test.tsx | 50 ++++++++++++++++--- .../src/__tests__/queryClient.test.tsx | 4 +- packages/query-core/src/mutationCache.ts | 13 ++--- packages/query-core/src/mutationObserver.ts | 13 ++++- packages/query-core/src/queryCache.ts | 15 +++--- packages/query-core/src/queryClient.ts | 36 +++++++++---- packages/query-core/src/types.ts | 21 ++++++++ packages/query-core/src/utils.ts | 38 ++++++++++---- 14 files changed, 168 insertions(+), 54 deletions(-) diff --git a/.changeset/query-cache-equality.md b/.changeset/query-cache-equality.md index ba1fbe6ca5f..7646c885dbe 100644 --- a/.changeset/query-cache-equality.md +++ b/.changeset/query-cache-equality.md @@ -2,4 +2,4 @@ '@tanstack/query-core': minor --- -Add an optional `equalityFn` to `QueryCache` and `MutationCache` for custom key comparisons. Extend `replaceEqualDeep` with an optional equality function for structural sharing of custom value types. +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/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index 10f6df3aeeb..e249dfcff40 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 3e3378cb3e9..09b24bdb11c 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 603094c9cc0..d8966609dc2 100644 --- a/docs/reference/MutationCache.md +++ b/docs/reference/MutationCache.md @@ -28,9 +28,10 @@ Its available methods are: **Options** -- `equalityFn?: (a: unknown, b: unknown) => boolean` +- `mutationKey?: { equalityFn?: (a: unknown, b: unknown) => boolean; hashFn?: (mutationKey: MutationKey) => string }` - Optional - - This function compares individual mutation key values during partial matching. It defaults to strict equality. + - `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 bb79e5fc12e..e68fa9e1d52 100644 --- a/docs/reference/QueryCache.md +++ b/docs/reference/QueryCache.md @@ -35,9 +35,10 @@ Its available methods are: **Options** -- `equalityFn?: (a: unknown, b: unknown) => boolean` +- `queryKey?: { equalityFn?: (a: unknown, b: unknown) => boolean; hashFn?: (queryKey: QueryKey) => string }` - Optional - - This function compares individual query key values during partial matching. It defaults to strict equality. + - `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 bdbdad472b3..d8e0be00530 100644 --- a/packages/query-core/src/__tests__/mutationCache.test.tsx +++ b/packages/query-core/src/__tests__/mutationCache.test.tsx @@ -309,7 +309,7 @@ describe('mutationCache', () => { typeof a === 'string' && typeof b === 'string' ? a.toLowerCase() === b.toLowerCase() : a === b - const testCache = new MutationCache({ equalityFn }) + const testCache = new MutationCache({ mutationKey: { equalityFn } }) const testClient = new QueryClient({ mutationCache: testCache }) const mutation = testCache.build(testClient, { mutationKey: ['todos', { status: 'done' }], @@ -330,6 +330,22 @@ describe('mutationCache', () => { 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__/queryCache.test.tsx b/packages/query-core/src/__tests__/queryCache.test.tsx index 2b1d8e87e36..e5856069858 100644 --- a/packages/query-core/src/__tests__/queryCache.test.tsx +++ b/packages/query-core/src/__tests__/queryCache.test.tsx @@ -181,7 +181,7 @@ describe('queryCache', () => { typeof a === 'string' && typeof b === 'string' ? a.toLowerCase() === b.toLowerCase() : a === b - const testCache = new QueryCache({ equalityFn }) + const testCache = new QueryCache({ queryKey: { equalityFn } }) const testClient = new QueryClient({ queryCache: testCache }) const query = testClient.getQueryCache().build(testClient, { queryKey: ['todos', { status: 'done' }], @@ -342,7 +342,9 @@ describe('queryCache', () => { const client = new QueryClient({ queryCache: new QueryCache({ - equalityFn: (a, b) => b === undefined || Object.is(a, b), + queryKey: { + equalityFn: (a, b) => b === undefined || Object.is(a, b), + }, }), }) @@ -361,11 +363,13 @@ describe('queryCache', () => { it('should invalidate matching dates (#10982)', async () => { const client = new QueryClient({ queryCache: new QueryCache({ - equalityFn: (a, b) => { - if (a instanceof Date && b instanceof Date) { - return a.toISOString() === b.toISOString() - } - return a === b + queryKey: { + equalityFn: (a, b) => { + if (a instanceof Date && b instanceof Date) { + return a.toISOString() === b.toISOString() + } + return a === b + }, }, }), }) @@ -442,6 +446,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 6498af039a0..39822fb2919 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -108,7 +108,7 @@ describe('queryClient', () => { ? a.toLowerCase() === b.toLowerCase() : a === b const testClient = new QueryClient({ - queryCache: new QueryCache({ equalityFn }), + queryCache: new QueryCache({ queryKey: { equalityFn } }), }) testClient.setQueryDefaults(['todos', { status: 'done' }], { @@ -3141,7 +3141,7 @@ describe('queryClient', () => { ? a.toLowerCase() === b.toLowerCase() : a === b const testClient = new QueryClient({ - mutationCache: new MutationCache({ equalityFn }), + mutationCache: new MutationCache({ mutationKey: { equalityFn } }), }) testClient.setMutationDefaults(['todos', { status: 'done' }], { diff --git a/packages/query-core/src/mutationCache.ts b/packages/query-core/src/mutationCache.ts index 05fd754f689..6dfd05550e4 100644 --- a/packages/query-core/src/mutationCache.ts +++ b/packages/query-core/src/mutationCache.ts @@ -4,9 +4,10 @@ import { matchMutation, noop } from './utils' import { Subscribable } from './subscribable' import type { MutationObserver } from './mutationObserver' import type { + CacheKeyConfig, DefaultError, - EqualityFn, MutationFunctionContext, + MutationKey, MutationOptions, NotifyEvent, } from './types' @@ -17,11 +18,7 @@ import type { MutationFilters } from './utils' // TYPES export interface MutationCacheConfig { - /** - * Function used to compare values while partially matching mutation keys. - * Defaults to strict equality. - */ - equalityFn?: EqualityFn + mutationKey?: CacheKeyConfig onError?: ( error: DefaultError, variables: unknown, @@ -218,13 +215,13 @@ export class MutationCache extends Subscribable { const defaultedFilters = { exact: true, ...filters } return this.getAll().find((mutation) => - matchMutation(defaultedFilters, mutation, this.config.equalityFn), + matchMutation(defaultedFilters, mutation, this.config.mutationKey), ) as Mutation | undefined } findAll(filters: MutationFilters = {}): Array { return this.getAll().filter((mutation) => - matchMutation(filters, mutation, this.config.equalityFn), + matchMutation(filters, mutation, this.config.mutationKey), ) } diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 8164e2ad024..28fe4e4ddde 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 { hashQueryKeyByOptions, shallowEqualObjects } from './utils' import type { QueryClient } from './queryClient' import type { DefaultError, @@ -85,7 +85,16 @@ export class MutationObserver< if ( prevOptions?.mutationKey && this.options.mutationKey && - hashKey(prevOptions.mutationKey) !== hashKey(this.options.mutationKey) + hashQueryKeyByOptions( + prevOptions.mutationKey, + undefined, + this.#client.getMutationCache().config.mutationKey?.hashFn, + ) !== + hashQueryKeyByOptions( + this.options.mutationKey, + undefined, + this.#client.getMutationCache().config.mutationKey?.hashFn, + ) ) { 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 1eef6271411..4b723f1e88d 100644 --- a/packages/query-core/src/queryCache.ts +++ b/packages/query-core/src/queryCache.ts @@ -5,8 +5,8 @@ import { Subscribable } from './subscribable' import type { QueryFilters } from './utils' import type { Action, QueryState } from './query' import type { + CacheKeyConfig, DefaultError, - EqualityFn, NotifyEvent, QueryKey, QueryOptions, @@ -18,11 +18,7 @@ import type { QueryObserver } from './queryObserver' // TYPES export interface QueryCacheConfig { - /** - * Function used to compare values while partially matching query keys. - * Defaults to strict equality. - */ - equalityFn?: EqualityFn + queryKey?: CacheKeyConfig onError?: ( error: DefaultError, query: Query, @@ -118,7 +114,8 @@ export class QueryCache extends Subscribable { ): Query { const queryKey = options.queryKey const queryHash = - options.queryHash ?? hashQueryKeyByOptions(queryKey, options) + options.queryHash ?? + hashQueryKeyByOptions(queryKey, options, this.config.queryKey?.hashFn) let query = this.get(queryHash) if (!query) { @@ -192,7 +189,7 @@ export class QueryCache extends Subscribable { const defaultedFilters = { exact: true, ...filters } return this.getAll().find((query) => - matchQuery(defaultedFilters, query, this.config.equalityFn), + matchQuery(defaultedFilters, query, this.config.queryKey), ) as Query | undefined } @@ -200,7 +197,7 @@ export class QueryCache extends Subscribable { const queries = this.getAll() return Object.keys(filters).length > 0 ? queries.filter((query) => - matchQuery(filters, query, this.config.equalityFn), + matchQuery(filters, query, this.config.queryKey), ) : queries } diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index a4204847916..24fc3f56844 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -1,6 +1,5 @@ import { functionalUpdate, - hashKey, hashQueryKeyByOptions, noop, partialMatchKey, @@ -560,10 +559,17 @@ export class QueryClient { > >, ): void { - this.#queryDefaults.set(hashKey(queryKey), { - queryKey, - defaultOptions: options, - }) + this.#queryDefaults.set( + hashQueryKeyByOptions( + queryKey, + undefined, + this.#queryCache.config.queryKey?.hashFn, + ), + { + queryKey, + defaultOptions: options, + }, + ) } getQueryDefaults( @@ -581,7 +587,7 @@ export class QueryClient { partialMatchKey( queryKey, queryDefault.queryKey, - this.#queryCache.config.equalityFn, + this.#queryCache.config.queryKey?.equalityFn, ) ) { Object.assign(result, queryDefault.defaultOptions) @@ -602,10 +608,17 @@ export class QueryClient { 'mutationKey' >, ): void { - this.#mutationDefaults.set(hashKey(mutationKey), { - mutationKey, - defaultOptions: options, - }) + this.#mutationDefaults.set( + hashQueryKeyByOptions( + mutationKey, + undefined, + this.#mutationCache.config.mutationKey?.hashFn, + ), + { + mutationKey, + defaultOptions: options, + }, + ) } getMutationDefaults( @@ -623,7 +636,7 @@ export class QueryClient { partialMatchKey( mutationKey, queryDefault.mutationKey, - this.#mutationCache.config.equalityFn, + this.#mutationCache.config.mutationKey?.equalityFn, ) ) { Object.assign(result, queryDefault.defaultOptions) @@ -685,6 +698,7 @@ export class QueryClient { defaultedOptions.queryHash = hashQueryKeyByOptions( defaultedOptions.queryKey, defaultedOptions, + this.#queryCache.config.queryKey?.hashFn, ) } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 46d478e051e..2d26c11bc85 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -195,6 +195,24 @@ export type QueryKeyHashFunction = ( 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, @@ -257,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 0eba5b66ccc..3a510cb9669 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,5 +1,7 @@ import { timeoutManager } from './timeoutManager' import type { + CacheKeyConfig, + CacheKeyHashFunction, DefaultError, EqualityFn, FetchStatus, @@ -135,7 +137,7 @@ export function resolveQueryValue< export function matchQuery( filters: QueryFilters, query: Query, - equalityFn?: EqualityFn, + keyConfig?: CacheKeyConfig, ): boolean { const { type = 'all', @@ -148,10 +150,15 @@ export function matchQuery( if (queryKey) { if (exact) { - if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) { + if ( + query.queryHash !== + hashQueryKeyByOptions(queryKey, query.options, keyConfig?.hashFn) + ) { return false } - } else if (!partialMatchKey(query.queryKey, queryKey, equalityFn)) { + } else if ( + !partialMatchKey(query.queryKey, queryKey, keyConfig?.equalityFn) + ) { return false } } @@ -184,7 +191,7 @@ export function matchQuery( export function matchMutation( filters: MutationFilters, mutation: Mutation, - equalityFn?: EqualityFn, + keyConfig?: CacheKeyConfig, ): boolean { const { exact, status, predicate, mutationKey } = filters if (mutationKey) { @@ -192,11 +199,21 @@ export function matchMutation( return false } if (exact) { - if (hashKey(mutation.options.mutationKey) !== hashKey(mutationKey)) { + if ( + hashQueryKeyByOptions( + mutation.options.mutationKey, + undefined, + keyConfig?.hashFn, + ) !== hashQueryKeyByOptions(mutationKey, undefined, keyConfig?.hashFn) + ) { return false } } else if ( - !partialMatchKey(mutation.options.mutationKey, mutationKey, equalityFn) + !partialMatchKey( + mutation.options.mutationKey, + mutationKey, + keyConfig?.equalityFn, + ) ) { return false } @@ -213,12 +230,15 @@ export function matchMutation( return true } -export function hashQueryKeyByOptions( +export function hashQueryKeyByOptions< + TQueryKey extends ReadonlyArray = QueryKey, +>( queryKey: TQueryKey, options?: Pick, 'queryKeyHashFn'>, + hashFn?: CacheKeyHashFunction, ): string { - const hashFn = options?.queryKeyHashFn || hashKey - return hashFn(queryKey) + const queryKeyHashFn = hashFn ?? options?.queryKeyHashFn ?? hashKey + return queryKeyHashFn(queryKey) } /** From 934d04f86012005c78a74215f21bed8d3d152c5a Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 30 Aug 2026 20:59:48 +0200 Subject: [PATCH 06/12] ref: simplify hashKeyByOptions --- packages/query-core/src/mutationObserver.ts | 12 +++++------ packages/query-core/src/queryCache.ts | 4 ++-- packages/query-core/src/queryClient.ts | 16 +++++++-------- packages/query-core/src/utils.ts | 22 +++++++++------------ 4 files changed, 23 insertions(+), 31 deletions(-) diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 28fe4e4ddde..09b285ff760 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 { hashQueryKeyByOptions, shallowEqualObjects } from './utils' +import { hashKeyByOptions, shallowEqualObjects } from './utils' import type { QueryClient } from './queryClient' import type { DefaultError, @@ -85,15 +85,13 @@ export class MutationObserver< if ( prevOptions?.mutationKey && this.options.mutationKey && - hashQueryKeyByOptions( + hashKeyByOptions( prevOptions.mutationKey, - undefined, - this.#client.getMutationCache().config.mutationKey?.hashFn, + this.#client.getMutationCache().config.mutationKey, ) !== - hashQueryKeyByOptions( + hashKeyByOptions( this.options.mutationKey, - undefined, - this.#client.getMutationCache().config.mutationKey?.hashFn, + this.#client.getMutationCache().config.mutationKey, ) ) { this.reset() diff --git a/packages/query-core/src/queryCache.ts b/packages/query-core/src/queryCache.ts index 4b723f1e88d..a54ebd640e4 100644 --- a/packages/query-core/src/queryCache.ts +++ b/packages/query-core/src/queryCache.ts @@ -1,4 +1,4 @@ -import { hashQueryKeyByOptions, matchQuery } from './utils' +import { hashKeyByOptions, matchQuery } from './utils' import { Query } from './query' import { notifyManager } from './notifyManager' import { Subscribable } from './subscribable' @@ -115,7 +115,7 @@ export class QueryCache extends Subscribable { const queryKey = options.queryKey const queryHash = options.queryHash ?? - hashQueryKeyByOptions(queryKey, options, this.config.queryKey?.hashFn) + hashKeyByOptions(queryKey, this.config.queryKey, options) let query = this.get(queryHash) if (!query) { diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 24fc3f56844..7a0fabff32f 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -1,6 +1,6 @@ import { functionalUpdate, - hashQueryKeyByOptions, + hashKeyByOptions, noop, partialMatchKey, resolveQueryValue, @@ -560,10 +560,9 @@ export class QueryClient { >, ): void { this.#queryDefaults.set( - hashQueryKeyByOptions( + hashKeyByOptions( queryKey, - undefined, - this.#queryCache.config.queryKey?.hashFn, + this.#queryCache.config.queryKey, ), { queryKey, @@ -609,10 +608,9 @@ export class QueryClient { >, ): void { this.#mutationDefaults.set( - hashQueryKeyByOptions( + hashKeyByOptions( mutationKey, - undefined, - this.#mutationCache.config.mutationKey?.hashFn, + this.#mutationCache.config.mutationKey, ), { mutationKey, @@ -695,10 +693,10 @@ export class QueryClient { } if (!defaultedOptions.queryHash) { - defaultedOptions.queryHash = hashQueryKeyByOptions( + defaultedOptions.queryHash = hashKeyByOptions( defaultedOptions.queryKey, + this.#queryCache.config.queryKey, defaultedOptions, - this.#queryCache.config.queryKey?.hashFn, ) } diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 3a510cb9669..04f4cd6276f 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,7 +1,6 @@ import { timeoutManager } from './timeoutManager' import type { CacheKeyConfig, - CacheKeyHashFunction, DefaultError, EqualityFn, FetchStatus, @@ -152,7 +151,7 @@ export function matchQuery( if (exact) { if ( query.queryHash !== - hashQueryKeyByOptions(queryKey, query.options, keyConfig?.hashFn) + hashKeyByOptions(queryKey, keyConfig, query.options) ) { return false } @@ -200,11 +199,8 @@ export function matchMutation( } if (exact) { if ( - hashQueryKeyByOptions( - mutation.options.mutationKey, - undefined, - keyConfig?.hashFn, - ) !== hashQueryKeyByOptions(mutationKey, undefined, keyConfig?.hashFn) + hashKeyByOptions(mutation.options.mutationKey, keyConfig) !== + hashKeyByOptions(mutationKey, keyConfig) ) { return false } @@ -230,15 +226,15 @@ export function matchMutation( return true } -export function hashQueryKeyByOptions< - TQueryKey extends ReadonlyArray = QueryKey, +export function hashKeyByOptions< + TCacheKey extends ReadonlyArray = ReadonlyArray, >( - queryKey: TQueryKey, + cacheKey: TCacheKey, + keyConfig: CacheKeyConfig | undefined, options?: Pick, 'queryKeyHashFn'>, - hashFn?: CacheKeyHashFunction, ): string { - const queryKeyHashFn = hashFn ?? options?.queryKeyHashFn ?? hashKey - return queryKeyHashFn(queryKey) + const queryKeyHashFn = keyConfig?.hashFn ?? options?.queryKeyHashFn ?? hashKey + return queryKeyHashFn(cacheKey) } /** From 283841357eb3c5d3f52da905933e5d5f07c88d4a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:01:53 +0000 Subject: [PATCH 07/12] ci: apply automated fixes --- packages/query-core/src/queryClient.ts | 10 ++-------- packages/query-core/src/utils.ts | 3 +-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 7a0fabff32f..01a32632117 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -560,10 +560,7 @@ export class QueryClient { >, ): void { this.#queryDefaults.set( - hashKeyByOptions( - queryKey, - this.#queryCache.config.queryKey, - ), + hashKeyByOptions(queryKey, this.#queryCache.config.queryKey), { queryKey, defaultOptions: options, @@ -608,10 +605,7 @@ export class QueryClient { >, ): void { this.#mutationDefaults.set( - hashKeyByOptions( - mutationKey, - this.#mutationCache.config.mutationKey, - ), + hashKeyByOptions(mutationKey, this.#mutationCache.config.mutationKey), { mutationKey, defaultOptions: options, diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 04f4cd6276f..bb717374225 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -150,8 +150,7 @@ export function matchQuery( if (queryKey) { if (exact) { if ( - query.queryHash !== - hashKeyByOptions(queryKey, keyConfig, query.options) + query.queryHash !== hashKeyByOptions(queryKey, keyConfig, query.options) ) { return false } From 7fb05901d83be8de02a1461e8749bb8d4a4cb4b3 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 30 Aug 2026 22:23:46 +0200 Subject: [PATCH 08/12] fix: rename --- packages/query-core/src/__tests__/query.test.tsx | 8 ++++---- packages/query-core/src/__tests__/utils.test.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index 5ac3f20663b..ae845965070 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -13,7 +13,7 @@ import { dehydrate, hydrate, } from '..' -import { hashQueryKeyByOptions } from '../utils' +import { hashKeyByOptions } from '../utils' import { mockOnlineManagerIsOnline, setIsServer } from './utils' import type { QueryFunctionContext, QueryKey, QueryObserverResult } from '..' @@ -1222,7 +1222,7 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashQueryKeyByOptions(key), + queryHash: hashKeyByOptions(key), }) query.addObserver(observer) @@ -1256,7 +1256,7 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashQueryKeyByOptions(key), + queryHash: hashKeyByOptions(key), options: { queryFn: () => 'data', initialData: initialDataFn, @@ -1339,7 +1339,7 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashQueryKeyByOptions(key), + queryHash: hashKeyByOptions(key), options: { queryFn }, }) diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index f827d2113cc..ed1245a5150 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,12 +23,12 @@ 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, { + const result = hashKeyByOptions(key, { queryKeyHashFn: customHashFn, }) @@ -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) expect(result).toEqual(defaultResult) }) From 1c66edf481743515fe0f595cdbfbc27bbfac35df Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 30 Aug 2026 22:25:40 +0200 Subject: [PATCH 09/12] fix test compile --- packages/query-core/src/__tests__/utils.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/query-core/src/__tests__/utils.test.tsx b/packages/query-core/src/__tests__/utils.test.tsx index ed1245a5150..e9873be8a24 100644 --- a/packages/query-core/src/__tests__/utils.test.tsx +++ b/packages/query-core/src/__tests__/utils.test.tsx @@ -29,7 +29,7 @@ describe('core/utils', () => { const customHashFn = vi.fn(() => 'custom-hash') const result = hashKeyByOptions(key, { - queryKeyHashFn: customHashFn, + 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 = hashKeyByOptions(key) + const result = hashKeyByOptions(key, undefined) expect(result).toEqual(defaultResult) }) From 2e3a1fe7532a0aa3332867ec53811f7a37a9d1e3 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 30 Aug 2026 22:30:29 +0200 Subject: [PATCH 10/12] more type errors in tests --- packages/query-core/src/__tests__/query.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index ae845965070..ee30e05945a 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1222,7 +1222,7 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashKeyByOptions(key), + queryHash: hashKeyByOptions(key, queryClient.getQueryCache().config.queryKey), }) query.addObserver(observer) @@ -1256,7 +1256,10 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashKeyByOptions(key), + queryHash: hashKeyByOptions( + key, + queryClient.getQueryCache().config.queryKey, + ), options: { queryFn: () => 'data', initialData: initialDataFn, @@ -1339,7 +1342,10 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashKeyByOptions(key), + queryHash: hashKeyByOptions( + key, + queryClient.getQueryCache().config.queryKey, + ), options: { queryFn }, }) From 752812806484c2e739b1b67af4029c07e3b853a3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:31:54 +0000 Subject: [PATCH 11/12] ci: apply automated fixes --- packages/query-core/src/__tests__/query.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index ee30e05945a..e33777d9420 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1222,7 +1222,10 @@ describe('query', () => { const query = new Query({ client: queryClient, queryKey: key, - queryHash: hashKeyByOptions(key, queryClient.getQueryCache().config.queryKey), + queryHash: hashKeyByOptions( + key, + queryClient.getQueryCache().config.queryKey, + ), }) query.addObserver(observer) From 4dd6bfa581523ef781467ae870f7f3fddc1b0804 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 30 Aug 2026 22:37:16 +0200 Subject: [PATCH 12/12] compare dates as example --- docs/framework/react/guides/render-optimizations.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/framework/react/guides/render-optimizations.md b/docs/framework/react/guides/render-optimizations.md index c73631cea17..0c9031cbf55 100644 --- a/docs/framework/react/guides/render-optimizations.md +++ b/docs/framework/react/guides/render-optimizations.md @@ -18,9 +18,7 @@ import { replaceEqualDeep, useQuery } from '@tanstack/react-query' const equalityFn = (a: unknown, b: unknown) => a === b || - (typeof a === 'bigint' && - typeof b === 'bigint' && - a.toString() === b.toString()) + (a instanceof Date && b instanceof Date && a.getTime() === b.getTime()) useQuery({ queryKey: ['events'],