Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/query-cache-equality.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions docs/framework/react/guides/render-optimizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/framework/react/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/framework/solid/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/MutationCache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> | unknown`
- Optional
- This function will be called if some mutation encounters an error.
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/QueryCache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions packages/query-core/src/__tests__/mutationCache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
17 changes: 13 additions & 4 deletions packages/query-core/src/__tests__/query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 '..'

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
})

Expand Down
109 changes: 109 additions & 0 deletions packages/query-core/src/__tests__/queryCache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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'
Expand Down
43 changes: 42 additions & 1 deletion packages/query-core/src/__tests__/queryClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,7 +18,6 @@ import { mockOnlineManagerIsOnline } from './utils'
import type {
InfiniteData,
Query,
QueryCache,
QueryFunction,
QueryObserverOptions,
} from '..'
Expand Down Expand Up @@ -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'], {
Expand Down Expand Up @@ -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()
})
})
})
Loading
Loading